using System.Numerics; using System.Text.RegularExpressions; using GFramework.Game.Abstractions.Config; namespace GFramework.Game.Config; /// /// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。 /// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致, /// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 /// 当前共享子集额外支持 multipleOfuniqueItems、 /// contains / minContains / maxContains、 /// minPropertiesmaxPropertiesdependentRequired、 /// dependentSchemasallOf、object-focused if / then / else /// 与稳定字符串 format 子集,让数值步进、数组去重、数组匹配计数、 /// 对象属性数量、对象内字段依赖、条件对象子 schema、对象组合约束与条件分支约束 /// 在运行时与生成器 / 工具侧保持一致。 /// internal static partial class YamlConfigSchemaValidator { // The runtime intentionally uses the same culture-invariant regex semantics as the // JS tooling so grouping and backreferences behave consistently across environments. private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant; private const string SupportedStringFormatNames = "'date', 'date-time', 'duration', 'email', 'time', 'uri', 'uuid'"; private static readonly TimeSpan SupportedFormatRegexTimeout = TimeSpan.FromSeconds(1); private static readonly Regex ExactDecimalPattern = new( @"^(?[+-]?)(?:(?\d+)(?:\.(?\d*))?|\.(?\d+))(?:[eE](?[+-]?\d+))?$", RegexOptions.CultureInvariant | RegexOptions.Compiled, SupportedFormatRegexTimeout); private static readonly Regex SupportedEmailFormatRegex = new( @"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.CultureInvariant | RegexOptions.Compiled, SupportedFormatRegexTimeout); private static readonly Regex SupportedDateFormatRegex = new( @"^(?\d{4})-(?\d{2})-(?\d{2})$", RegexOptions.CultureInvariant | RegexOptions.Compiled, SupportedFormatRegexTimeout); private static readonly Regex SupportedDateTimeFormatRegex = new( @"^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$", RegexOptions.CultureInvariant | RegexOptions.Compiled, SupportedFormatRegexTimeout); private static readonly Regex SupportedDurationFormatRegex = new( @"^P(?:(?\d+)D)?(?:T(?:(?\d+)H)?(?:(?\d+)M)?(?:(?\d+(?:\.\d+)?)S)?)?$", RegexOptions.CultureInvariant | RegexOptions.Compiled, SupportedFormatRegexTimeout); private static readonly Regex SupportedTimeFormatRegex = new( @"^(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$", RegexOptions.CultureInvariant | RegexOptions.Compiled, SupportedFormatRegexTimeout); private static readonly Regex SupportedUriSchemeRegex = new( @"^[A-Za-z][A-Za-z0-9+\.-]*:", RegexOptions.CultureInvariant | RegexOptions.Compiled, SupportedFormatRegexTimeout); /// /// 从磁盘加载并解析一个 JSON Schema 文件。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 取消令牌。 /// 解析后的 schema 模型。 /// 为空时抛出。 /// 为空时抛出。 /// 当 schema 文件不存在或内容非法时抛出。 internal static async Task LoadAsync( string tableName, string schemaPath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(tableName)) { throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName)); } if (string.IsNullOrWhiteSpace(schemaPath)) { throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath)); } if (!File.Exists(schemaPath)) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaFileNotFound, tableName, $"Schema file '{schemaPath}' was not found.", schemaPath: schemaPath); } string schemaText; try { schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken).ConfigureAwait(false); } catch (Exception exception) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaReadFailed, tableName, $"Failed to read schema file '{schemaPath}'.", schemaPath: schemaPath, innerException: exception); } return ParseLoadedSchema(tableName, schemaPath, schemaText); } /// /// 从磁盘同步加载并解析一个 JSON Schema 文件。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 解析后的 schema 模型。 /// 为空时抛出。 /// 为空时抛出。 /// 当 schema 文件不存在或内容非法时抛出。 internal static YamlConfigSchema Load( string tableName, string schemaPath) { if (string.IsNullOrWhiteSpace(tableName)) { throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName)); } if (string.IsNullOrWhiteSpace(schemaPath)) { throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath)); } if (!File.Exists(schemaPath)) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaFileNotFound, tableName, $"Schema file '{schemaPath}' was not found.", schemaPath: schemaPath); } string schemaText; try { schemaText = File.ReadAllText(schemaPath); } catch (Exception exception) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaReadFailed, tableName, $"Failed to read schema file '{schemaPath}'.", schemaPath: schemaPath, innerException: exception); } return ParseLoadedSchema(tableName, schemaPath, schemaText); } /// /// 使用已解析的 schema 校验 YAML 文本。 /// /// 所属配置表名称。 /// 已解析的 schema 模型。 /// YAML 文件路径,仅用于诊断信息。 /// YAML 文本内容。 /// 当参数为空时抛出。 /// 当 YAML 内容与 schema 不匹配时抛出。 internal static void Validate( string tableName, YamlConfigSchema schema, string yamlPath, string yamlText) { ValidateCore(tableName, schema, yamlPath, yamlText, references: null); } /// /// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。 /// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。 /// /// 所属配置表名称。 /// 已解析的 schema 模型。 /// YAML 文件路径,仅用于诊断信息。 /// YAML 文本内容。 /// 当前 YAML 文件中声明的跨表引用集合。 /// 当参数为空时抛出。 /// 当 YAML 内容与 schema 不匹配时抛出。 internal static IReadOnlyList ValidateAndCollectReferences( string tableName, YamlConfigSchema schema, string yamlPath, string yamlText) { var references = new List(); ValidateCore(tableName, schema, yamlPath, yamlText, references); return references; } /// /// 执行共享的 YAML 结构校验流程,并按需收集跨表引用。 /// 这样 可以复用同一条校验链路,同时避免为“不关心引用结果”的调用方分配临时列表。 /// /// 所属配置表名称。 /// 已解析的 schema 模型。 /// YAML 文件路径,仅用于诊断信息。 /// YAML 文本内容。 /// 可选的跨表引用收集器;为 时只做结构校验。 /// 当参数为空时抛出。 /// 当 YAML 内容与 schema 不匹配时抛出。 private static void ValidateCore( string tableName, YamlConfigSchema schema, string yamlPath, string yamlText, ICollection? references) { if (string.IsNullOrWhiteSpace(tableName)) { throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName)); } ArgumentNullException.ThrowIfNull(schema); ArgumentNullException.ThrowIfNull(yamlPath); ArgumentNullException.ThrowIfNull(yamlText); YamlStream yamlStream = new(); try { using var reader = new StringReader(yamlText); yamlStream.Load(reader); } catch (Exception exception) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.YamlParseFailed, tableName, $"Config file '{yamlPath}' could not be parsed as YAML before schema validation.", yamlPath: yamlPath, schemaPath: schema.SchemaPath, innerException: exception); } if (yamlStream.Documents.Count != 1) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.InvalidYamlDocument, tableName, $"Config file '{yamlPath}' must contain exactly one YAML document.", yamlPath: yamlPath, schemaPath: schema.SchemaPath); } ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references); } /// /// 解析已读取到内存中的 schema 文本,并构造运行时最小模型。 /// /// 所属配置表名称。 /// Schema 文件路径,仅用于诊断信息。 /// Schema 文本内容。 /// 解析后的 schema 模型。 private static YamlConfigSchema ParseLoadedSchema( string tableName, string schemaPath, string schemaText) { try { using var document = JsonDocument.Parse(schemaText); var root = document.RootElement; var rootNode = ParseNode(tableName, schemaPath, "", root, isRoot: true); if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Schema file '{schemaPath}' must declare a root object schema.", schemaPath: schemaPath); } var referencedTableNames = new HashSet(StringComparer.Ordinal); CollectReferencedTableNames(rootNode, referencedTableNames); // Preserve a deterministic dependency order so hot-reload bookkeeping and tests // do not depend on HashSet enumeration details. var orderedReferencedTableNames = referencedTableNames .OrderBy(static name => name, StringComparer.Ordinal) .ToArray(); return new YamlConfigSchema(schemaPath, rootNode, orderedReferencedTableNames); } catch (JsonException exception) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaInvalidJson, tableName, $"Schema file '{schemaPath}' contains invalid JSON.", schemaPath: schemaPath, innerException: exception); } } /// /// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 当前节点的逻辑属性路径。 /// Schema JSON 节点。 /// 是否为根节点。 /// 可用于运行时校验的节点模型。 private static YamlConfigSchemaNode ParseNode( string tableName, string schemaPath, string propertyPath, JsonElement element, bool isRoot = false) { ValidateUnsupportedCombinatorKeywords(tableName, schemaPath, propertyPath, element); ValidateUnsupportedOpenObjectKeywords(tableName, schemaPath, propertyPath, element); var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element); var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName); var parsedNode = CreateParsedNodeForType( tableName, schemaPath, propertyPath, element, typeName, referenceTableName, isRoot); return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element)); } /// /// 显式拒绝当前共享子集中尚未支持、且会改变生成类型形状的组合关键字。 /// 这样 Runtime / Generator / Tooling 会对同一份 schema 给出一致失败, /// 而不是默默忽略 oneOf / anyOf 造成接受范围漂移。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 当前节点的逻辑属性路径。 /// 当前 schema 节点。 private static void ValidateUnsupportedCombinatorKeywords( string tableName, string schemaPath, string propertyPath, JsonElement element) { if (TryGetUnsupportedCombinatorKeywordName(element) is not { } keywordName) { return; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares unsupported combinator keyword '{keywordName}'. " + "The current config schema subset does not support combinators that can change generated type shape.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } /// /// 显式拒绝当前共享子集中尚未支持的开放对象关键字形状。 /// 当前配置系统默认采用闭合对象字段集;这里只接受显式重复该语义的 /// additionalProperties: false,继续拒绝会引入动态字段形状的其它形式。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 当前节点的逻辑属性路径。 /// 当前 schema 节点。 private static void ValidateUnsupportedOpenObjectKeywords( string tableName, string schemaPath, string propertyPath, JsonElement element) { if (!element.TryGetProperty("additionalProperties", out var additionalPropertiesElement)) { return; } if (additionalPropertiesElement.ValueKind == JsonValueKind.False) { return; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported 'additionalProperties' metadata. " + "The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } /// /// 返回当前节点声明的首个未支持组合关键字。 /// /// 当前 schema 节点。 /// 命中的关键字名称;未声明时返回空。 private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element) { return element.TryGetProperty("oneOf", out _) ? "oneOf" : element.TryGetProperty("anyOf", out _) ? "anyOf" : null; } /// /// 解析 schema 节点声明的类型名称,并在缺失或类型错误时立刻给出定位清晰的诊断。 /// private static string ResolveNodeTypeName( string tableName, string schemaPath, string propertyPath, JsonElement element) { if (!element.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'type'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return typeElement.GetString() ?? string.Empty; } /// /// 限制只允许对象 schema 使用对象专属关键字,避免后续分支在运行时才发现语义不兼容。 /// private static void ValidateObjectOnlyKeywords( string tableName, string schemaPath, string propertyPath, JsonElement element, string typeName) { if (string.Equals(typeName, "object", StringComparison.Ordinal) || TryGetObjectOnlyKeywordName(element) is not { } objectOnlyKeywordName) { return; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' can only declare '{objectOnlyKeywordName}' on object schemas.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } /// /// 根据声明的 schema 类型分派到对应的节点解析器。 /// private static YamlConfigSchemaNode CreateParsedNodeForType( string tableName, string schemaPath, string propertyPath, JsonElement element, string typeName, string? referenceTableName, bool isRoot) { return typeName switch { "object" => ParseObjectSchemaNode( tableName, schemaPath, propertyPath, element, referenceTableName, isRoot), "array" => ParseArrayNode(tableName, schemaPath, propertyPath, element, referenceTableName), "integer" => CreateScalarNode( tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Integer, element, referenceTableName), "number" => CreateScalarNode( tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Number, element, referenceTableName), "boolean" => CreateScalarNode( tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Boolean, element, referenceTableName), "string" => CreateScalarNode( tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.String, element, referenceTableName), _ => throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath), rawValue: typeName) }; } /// /// 解析对象类型 schema,并在进入对象节点解析前先校验 ref-table 是否兼容。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 对象属性路径。 /// 对象 schema 节点。 /// 声明在当前节点上的目标引用表。 /// 是否为根节点。 /// 对象节点模型。 private static YamlConfigSchemaNode ParseObjectSchemaNode( string tableName, string schemaPath, string propertyPath, JsonElement element, string? referenceTableName, bool isRoot) { EnsureReferenceKeywordIsSupported( tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Object, referenceTableName); return ParseObjectNode(tableName, schemaPath, propertyPath, element, isRoot); } /// /// 解析对象节点,保留属性字典与必填集合,以便后续递归校验时逐层定位错误。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 对象属性路径。 /// 对象 schema 节点。 /// 是否为根节点。 /// 对象节点模型。 private static YamlConfigSchemaNode ParseObjectNode( string tableName, string schemaPath, string propertyPath, JsonElement element, bool isRoot) { if (!element.TryGetProperty("properties", out var propertiesElement) || propertiesElement.ValueKind != JsonValueKind.Object) { var subject = isRoot ? "root schema" : $"object property '{propertyPath}'"; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"The {subject} in schema file '{schemaPath}' must declare an object-valued 'properties' section.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var requiredProperties = new HashSet(StringComparer.Ordinal); if (element.TryGetProperty("required", out var requiredElement) && requiredElement.ValueKind == JsonValueKind.Array) { foreach (var item in requiredElement.EnumerateArray()) { if (item.ValueKind != JsonValueKind.String) { continue; } var requiredPropertyName = item.GetString(); if (!string.IsNullOrWhiteSpace(requiredPropertyName)) { requiredProperties.Add(requiredPropertyName); } } } var properties = new Dictionary(StringComparer.Ordinal); foreach (var property in propertiesElement.EnumerateObject()) { properties[property.Name] = ParseNode( tableName, schemaPath, CombineSchemaPath(propertyPath, property.Name), property.Value); } var objectNode = YamlConfigSchemaNode.CreateObject( properties, requiredProperties, ParseObjectConstraints(tableName, schemaPath, propertyPath, element, properties), schemaPath); objectNode = objectNode.WithAllowedValues( ParseEnumValues(tableName, schemaPath, propertyPath, element, objectNode, "enum")); return objectNode.WithConstantValue( ParseConstantValue(tableName, schemaPath, propertyPath, element, objectNode)); } /// /// 解析数组节点。 /// 当前子集支持标量数组和对象数组,不支持数组嵌套数组。 /// 当数组声明跨表引用时,会把引用语义挂到元素节点上,便于后续逐项校验。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 数组属性路径。 /// 数组 schema 节点。 /// 声明在数组节点上的目标引用表。 /// 数组节点模型。 private static YamlConfigSchemaNode ParseArrayNode( string tableName, string schemaPath, string propertyPath, JsonElement element, string? referenceTableName) { if (!element.TryGetProperty("items", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Object) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Array property '{propertyPath}' in schema file '{schemaPath}' must declare an object-valued 'items' schema.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var itemNode = ParseNode(tableName, schemaPath, $"{propertyPath}[]", itemsElement); if (!string.IsNullOrWhiteSpace(referenceTableName)) { if (itemNode.NodeType != YamlConfigSchemaPropertyType.String && itemNode.NodeType != YamlConfigSchemaPropertyType.Integer) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath), referencedTableName: referenceTableName); } itemNode = itemNode.WithReferenceTable(referenceTableName); } if (itemNode.NodeType == YamlConfigSchemaPropertyType.Array) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Array property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array items.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var arrayNode = YamlConfigSchemaNode.CreateArray( itemNode, allowedValues: null, ParseArrayConstraints(tableName, schemaPath, propertyPath, element), schemaPath); arrayNode = arrayNode.WithAllowedValues( ParseEnumValues(tableName, schemaPath, propertyPath, element, arrayNode, "enum")); return arrayNode.WithConstantValue( ParseConstantValue(tableName, schemaPath, propertyPath, element, arrayNode)); } /// /// 创建标量节点,并在解析阶段就完成 enum 与引用约束的兼容性检查。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 标量属性路径。 /// 标量类型。 /// 标量 schema 节点。 /// 目标引用表名称。 /// 标量节点模型。 private static YamlConfigSchemaNode CreateScalarNode( string tableName, string schemaPath, string propertyPath, YamlConfigSchemaPropertyType nodeType, JsonElement element, string? referenceTableName) { EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName); var scalarNode = YamlConfigSchemaNode.CreateScalar( nodeType, referenceTableName, allowedValues: null, ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType), schemaPath); scalarNode = scalarNode.WithAllowedValues( ParseEnumValues(tableName, schemaPath, propertyPath, element, scalarNode, "enum")); return scalarNode.WithConstantValue( ParseConstantValue(tableName, schemaPath, propertyPath, element, scalarNode)); } /// /// 解析节点上的 not 约束。 /// 该子 schema 继续复用同一套节点解析逻辑,保证 Runtime / Generator / Tooling /// 对深层结构与格式白名单的解释保持一致。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 当前节点路径。 /// Schema JSON 节点。 /// 解析后的 negated schema;未声明时返回空。 private static YamlConfigSchemaNode? ParseNegatedSchemaNode( string tableName, string schemaPath, string propertyPath, JsonElement element) { if (!element.TryGetProperty("not", out var notElement)) { return null; } if (notElement.ValueKind != JsonValueKind.Object) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'not' as an object-valued schema.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return ParseNode( tableName, schemaPath, BuildNestedSchemaPath(propertyPath, "not"), notElement); } /// /// 为 contains / not / dependentSchemas 这类内联子 schema 构建稳定的诊断路径。 /// /// 当前节点路径。 /// 内联子 schema 后缀。 /// 带内联后缀的 schema 路径。 private static string BuildNestedSchemaPath(string propertyPath, string suffix) { return string.IsNullOrWhiteSpace(propertyPath) ? $"[{suffix}]" : $"{propertyPath}[{suffix}]"; } /// /// 递归校验 YAML 节点。 /// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 当前字段路径;根节点时为空。 /// 实际 YAML 节点。 /// 对应的 schema 节点。 /// 已收集的跨表引用。 /// /// 是否允许对象节点出现当前 schema 子树未声明的额外字段。 /// 该开关仅用于 contains 试匹配,让对象子 schema 可以按“声明属性子集匹配”工作; /// 正常加载主链路仍保持未知字段即失败的严格语义。 /// private static void ValidateNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, ICollection? references, bool allowUnknownObjectProperties = false) { switch (schemaNode.NodeType) { case YamlConfigSchemaPropertyType.Object: ValidateObjectNode( tableName, yamlPath, displayPath, node, schemaNode, references, allowUnknownObjectProperties); return; case YamlConfigSchemaPropertyType.Array: ValidateArrayNode( tableName, yamlPath, displayPath, node, schemaNode, references, allowUnknownObjectProperties); return; case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Number: case YamlConfigSchemaPropertyType.Boolean: case YamlConfigSchemaPropertyType.String: ValidateScalarNode(tableName, yamlPath, displayPath, node, schemaNode, references); return; default: throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.UnexpectedFailure, tableName, $"Schema node '{displayPath}' uses unsupported runtime node type '{schemaNode.NodeType}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: schemaNode.NodeType.ToString()); } } /// /// 校验对象节点,同时处理重复字段、未知字段和深层必填字段。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 当前对象的逻辑字段路径。 /// 实际 YAML 节点。 /// 对象 schema 节点。 /// 已收集的跨表引用。 /// /// 是否允许当前对象包含 schema 子树未声明的额外字段。 /// private static void ValidateObjectNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, ICollection? references, bool allowUnknownObjectProperties) { var mappingNode = GetObjectMappingNode(tableName, yamlPath, displayPath, node, schemaNode); var seenProperties = ValidateObjectPropertyEntries( tableName, yamlPath, displayPath, mappingNode, schemaNode, references, allowUnknownObjectProperties); ValidateRequiredObjectProperties(tableName, yamlPath, displayPath, schemaNode, seenProperties); ValidateObjectConstraints( tableName, yamlPath, displayPath, mappingNode, seenProperties, schemaNode, references); ValidateAllowedValues(tableName, yamlPath, displayPath, mappingNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode); ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, mappingNode, schemaNode); } /// /// 确认当前 YAML 节点确实是对象节点,避免后续属性枚举阶段再做重复判空与类型判断。 /// private static YamlMappingNode GetObjectMappingNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode) { if (node is YamlMappingNode mappingNode) { return mappingNode; } var subject = displayPath.Length == 0 ? "Root object" : $"Property '{displayPath}'"; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.PropertyTypeMismatch, tableName, $"{subject} in config file '{yamlPath}' must be an object.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath)); } /// /// 遍历对象属性并递归校验每个已声明字段,同时记录用于后续 required 与 dependency 判断的 sibling 集合。 /// private static HashSet ValidateObjectPropertyEntries( string tableName, string yamlPath, string displayPath, YamlMappingNode mappingNode, YamlConfigSchemaNode schemaNode, ICollection? references, bool allowUnknownObjectProperties) { var seenProperties = new HashSet(StringComparer.Ordinal); foreach (var entry in mappingNode.Children) { if (entry.Key is not YamlScalarNode keyNode || string.IsNullOrWhiteSpace(keyNode.Value)) { var subject = displayPath.Length == 0 ? "root object" : $"object property '{displayPath}'"; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.InvalidYamlDocument, tableName, $"Config file '{yamlPath}' contains a non-scalar or empty property name inside {subject}.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath)); } var propertyName = keyNode.Value; var propertyPath = CombineDisplayPath(displayPath, propertyName); if (!seenProperties.Add(propertyName)) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.DuplicateProperty, tableName, $"Config file '{yamlPath}' contains duplicate property '{propertyPath}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: propertyPath); } if (schemaNode.Properties is null || !schemaNode.Properties.TryGetValue(propertyName, out var propertySchema)) { if (allowUnknownObjectProperties) { continue; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.UnknownProperty, tableName, $"Config file '{yamlPath}' contains unknown property '{propertyPath}' that is not declared in schema '{schemaNode.SchemaPathHint}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: propertyPath); } ValidateNode( tableName, yamlPath, propertyPath, entry.Value, propertySchema, references, allowUnknownObjectProperties); } return seenProperties; } /// /// 在对象主体字段遍历结束后统一检查缺失的 required 字段,保证错误消息使用稳定的完整路径。 /// private static void ValidateRequiredObjectProperties( string tableName, string yamlPath, string displayPath, YamlConfigSchemaNode schemaNode, HashSet seenProperties) { if (schemaNode.RequiredProperties is null) { return; } foreach (var requiredProperty in schemaNode.RequiredProperties) { if (seenProperties.Contains(requiredProperty)) { continue; } var requiredPath = CombineDisplayPath(displayPath, requiredProperty); throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.MissingRequiredProperty, tableName, $"Config file '{yamlPath}' is missing required property '{requiredPath}' defined by schema '{schemaNode.SchemaPathHint}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: requiredPath); } } /// /// 校验对象节点声明的数量约束与条件对象约束。 /// 该阶段除了检查 minProperties / maxProperties,还会复用同一份 sibling 集合处理 /// dependentRequired,并在 dependentSchemas 命中时以 focused constraint block 语义 /// 对整个 做额外试匹配;若声明了 object-focused /// if / then / else,则先按同样的 focused matcher 判断条件分支,再只对命中的分支追加约束。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 对象字段路径;根对象时为空。 /// 当前 YAML 对象节点;用于让条件子 schema 在完整对象视图上做匹配。 /// 当前对象已出现的属性集合。 /// 对象 schema 节点。 /// /// 可选的跨表引用收集器;当 dependentSchemasallOf 或条件分支命中且匹配成功时, /// 只会回写对应内联分支新增的引用。 /// private static void ValidateObjectConstraints( string tableName, string yamlPath, string displayPath, YamlMappingNode mappingNode, HashSet seenProperties, YamlConfigSchemaNode schemaNode, ICollection? references) { var constraints = schemaNode.ObjectConstraints; if (constraints is null) { return; } var propertyCount = seenProperties.Count; var subject = GetObjectConstraintSubject(displayPath); ValidateObjectPropertyCountConstraints( tableName, yamlPath, displayPath, schemaNode, constraints, subject, propertyCount); ValidateDependentRequiredConstraints( tableName, yamlPath, displayPath, schemaNode, constraints, seenProperties); ValidateDependentSchemas( tableName, yamlPath, displayPath, mappingNode, schemaNode, constraints, references, seenProperties, subject); ValidateAllOfSchemas( tableName, yamlPath, displayPath, mappingNode, schemaNode, constraints, references, subject); ValidateConditionalObjectSchemas( tableName, yamlPath, displayPath, mappingNode, schemaNode, constraints, references, subject); } /// /// 为对象约束构造统一的诊断主语,保证根对象与嵌套对象的错误消息格式一致。 /// private static string GetObjectConstraintSubject(string displayPath) { return string.IsNullOrWhiteSpace(displayPath) ? "Root object" : $"Property '{displayPath}'"; } /// /// 校验对象属性数量上下限。 /// private static void ValidateObjectPropertyCountConstraints( string tableName, string yamlPath, string displayPath, YamlConfigSchemaNode schemaNode, YamlConfigObjectConstraints constraints, string subject, int propertyCount) { var rawValue = propertyCount.ToString(CultureInfo.InvariantCulture); if (constraints.MinProperties.HasValue && propertyCount < constraints.MinProperties.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"{subject} in config file '{yamlPath}' must contain at least {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)} properties.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Minimum property count: {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)}."); } if (constraints.MaxProperties.HasValue && propertyCount > constraints.MaxProperties.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"{subject} in config file '{yamlPath}' must contain at most {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)} properties.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}."); } } /// /// 使用已见 sibling 集合校验 dependentRequired,确保对象主路径与试匹配路径共用同一判定语义。 /// private static void ValidateDependentRequiredConstraints( string tableName, string yamlPath, string displayPath, YamlConfigSchemaNode schemaNode, YamlConfigObjectConstraints constraints, HashSet seenProperties) { if (constraints.DependentRequired is null || constraints.DependentRequired.Count == 0) { return; } // Reuse the collected sibling-name set so the main validation path and // the contains/not matcher both interpret object dependencies identically. foreach (var dependency in constraints.DependentRequired) { if (!seenProperties.Contains(dependency.Key)) { continue; } var triggerPath = CombineDisplayPath(displayPath, dependency.Key); foreach (var dependentProperty in dependency.Value) { if (seenProperties.Contains(dependentProperty)) { continue; } var requiredPath = CombineDisplayPath(displayPath, dependentProperty); throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.MissingRequiredProperty, tableName, $"Property '{requiredPath}' in config file '{yamlPath}' is required when sibling property '{triggerPath}' is present.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: requiredPath, detail: $"Dependent requirement: when '{triggerPath}' exists, '{requiredPath}' must also be declared."); } } } /// /// 在触发字段出现时,以 focused matcher 语义试跑 dependentSchemas。 /// private static void ValidateDependentSchemas( string tableName, string yamlPath, string displayPath, YamlMappingNode mappingNode, YamlConfigSchemaNode schemaNode, YamlConfigObjectConstraints constraints, ICollection? references, HashSet seenProperties, string subject) { if (constraints.DependentSchemas is null || constraints.DependentSchemas.Count == 0) { return; } 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."); } } /// /// 逐条校验 allOf 约束,保持与 dependentSchemas 相同的 focused object 匹配语义。 /// private static void ValidateAllOfSchemas( string tableName, string yamlPath, string displayPath, YamlMappingNode mappingNode, YamlConfigSchemaNode schemaNode, YamlConfigObjectConstraints constraints, ICollection? references, string subject) { if (constraints.AllOfSchemas is null || constraints.AllOfSchemas.Count == 0) { return; } for (var index = 0; index < constraints.AllOfSchemas.Count; index++) { 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, allOfSchema, references, allowUnknownObjectProperties: true)) { continue; } var allOfEntryNumber = index + 1; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"{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: $"allOf entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} must match the current object."); } } /// /// 执行对象级 if/then/else 约束,并沿用 focused matcher 允许条件 schema 只声明关注字段。 /// private static void ValidateConditionalObjectSchemas( string tableName, string yamlPath, string displayPath, YamlMappingNode mappingNode, YamlConfigSchemaNode schemaNode, YamlConfigObjectConstraints constraints, ICollection? references, string subject) { var conditionalSchemas = constraints.ConditionalSchemas; if (conditionalSchemas is null) { return; } // if/then/else follows the same object-focused matcher contract as dependentSchemas/allOf: // condition evaluation can inspect a subset of the current object without forcing unrelated // sibling fields to be re-declared inside the branch schema. var ifMatched = TryMatchSchemaNode( tableName, yamlPath, displayPath, mappingNode, conditionalSchemas.IfSchema, references, allowUnknownObjectProperties: true); ValidateConditionalSchemaBranch( tableName, yamlPath, displayPath, mappingNode, schemaNode, references, subject, ifMatched, conditionalSchemas.ThenSchema, branchName: "then", failureDetail: "Conditional schema: the current object matched the inline 'if' schema, so it must also satisfy the corresponding 'then' schema."); ValidateConditionalSchemaBranch( tableName, yamlPath, displayPath, mappingNode, schemaNode, references, subject, !ifMatched, conditionalSchemas.ElseSchema, branchName: "else", failureDetail: "Conditional schema: the current object did not match the inline 'if' schema, so it must satisfy the corresponding 'else' schema."); } /// /// 校验 if/then/else 的单个分支,并在条件命中但分支未通过时提供统一诊断。 /// private static void ValidateConditionalSchemaBranch( string tableName, string yamlPath, string displayPath, YamlMappingNode mappingNode, YamlConfigSchemaNode schemaNode, ICollection? references, string subject, bool shouldValidate, YamlConfigSchemaNode? branchSchema, string branchName, string failureDetail) { if (!shouldValidate || branchSchema is null || TryMatchSchemaNode( tableName, yamlPath, displayPath, mappingNode, branchSchema, references, allowUnknownObjectProperties: true)) { return; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"{subject} in config file '{yamlPath}' must satisfy the '{branchName}' schema because the inline 'if' condition {(string.Equals(branchName, "then", StringComparison.Ordinal) ? "matched" : "did not match")}.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), detail: failureDetail); } /// /// 校验数组节点,并递归验证每个元素。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 数组字段路径。 /// 实际 YAML 节点。 /// 数组 schema 节点。 /// 已收集的跨表引用。 /// /// 是否允许数组元素内的对象节点包含 schema 子树未声明的额外字段。 /// private static void ValidateArrayNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, ICollection? references, bool allowUnknownObjectProperties) { if (node is not YamlSequenceNode sequenceNode) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.PropertyTypeMismatch, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be an array.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath)); } if (schemaNode.ItemNode is null) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.UnexpectedFailure, tableName, $"Schema node '{displayPath}' is missing array item information.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath)); } if (schemaNode.ArrayConstraints is not null) { ValidateArrayConstraints(tableName, yamlPath, displayPath, sequenceNode.Children.Count, schemaNode); } for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) { ValidateNode( tableName, yamlPath, $"{displayPath}[{itemIndex}]", sequenceNode.Children[itemIndex], schemaNode.ItemNode, references, allowUnknownObjectProperties); } ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references); ValidateAllowedValues(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } /// /// 校验标量节点,并在值有效时收集跨表引用。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 标量字段路径。 /// 实际 YAML 节点。 /// 标量 schema 节点。 /// 已收集的跨表引用。 private static void ValidateScalarNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, ICollection? references) { var scalarNode = GetScalarNodeOrThrow(tableName, yamlPath, displayPath, node, schemaNode); var value = GetScalarValueOrThrow(tableName, yamlPath, displayPath, scalarNode, schemaNode); ValidateScalarTypeOrThrow(tableName, yamlPath, displayPath, scalarNode, schemaNode, value); var normalizedValue = NormalizeScalarValue(schemaNode.NodeType, value); ValidateAllowedValues(tableName, yamlPath, displayPath, scalarNode, schemaNode); if (schemaNode.Constraints is not null) { ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode); } ValidateConstantValue(tableName, yamlPath, displayPath, scalarNode, schemaNode); ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, scalarNode, schemaNode); CollectScalarReferenceUsage(yamlPath, displayPath, schemaNode, references, normalizedValue); } /// /// 确认 schema 期望的节点在 YAML 中仍然表现为标量。 /// private static YamlScalarNode GetScalarNodeOrThrow( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode) { if (node is YamlScalarNode scalarNode) { return scalarNode; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.PropertyTypeMismatch, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(schemaNode.NodeType)}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath)); } /// /// 读取标量文本值,并把 YAML 显式 null 与普通空字符串区分开。 /// private static string GetScalarValueOrThrow( string tableName, string yamlPath, string displayPath, YamlScalarNode scalarNode, YamlConfigSchemaNode schemaNode) { if (scalarNode.Value is { } value) { return value; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.NullScalarValue, tableName, $"Property '{displayPath}' in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(schemaNode.NodeType)}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath)); } /// /// 按 schema 声明的标量类型验证 YAML 文本值。 /// private static void ValidateScalarTypeOrThrow( string tableName, string yamlPath, string displayPath, YamlScalarNode scalarNode, YamlConfigSchemaNode schemaNode, string value) { var tag = scalarNode.Tag.ToString(); var isValid = schemaNode.NodeType switch { YamlConfigSchemaPropertyType.String => IsStringScalar(tag), YamlConfigSchemaPropertyType.Integer => long.TryParse( value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _), YamlConfigSchemaPropertyType.Number => double.TryParse( value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out _), YamlConfigSchemaPropertyType.Boolean => bool.TryParse(value, out _), _ => false }; if (isValid) { return; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.PropertyTypeMismatch, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be of type '{GetTypeName(schemaNode.NodeType)}', but the current YAML scalar value is '{value}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: value); } /// /// 在标量值成功通过本地校验后,再把引用信息回写给外层收集器。 /// private static void CollectScalarReferenceUsage( string yamlPath, string displayPath, YamlConfigSchemaNode schemaNode, ICollection? references, string normalizedValue) { if (schemaNode.ReferenceTableName is null || references is null) { return; } references.Add( new YamlConfigReferenceUsage( yamlPath, schemaNode.SchemaPathHint, displayPath, normalizedValue, schemaNode.ReferenceTableName, schemaNode.NodeType)); } /// /// 解析 enum,并在读取阶段将每个候选值预归一化成与运行时 YAML 相同的稳定比较键。 /// 这样对象、数组和标量节点都可以复用同一套匹配逻辑,而不必在每次加载时重新解释 schema 字面量。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 当前节点的已解析 schema 模型。 /// 当前读取的关键字名称。 /// 归一化后的允许值集合;未声明时返回空。 private static IReadOnlyCollection? ParseEnumValues( string tableName, string schemaPath, string propertyPath, JsonElement element, YamlConfigSchemaNode schemaNode, string keywordName) { if (!element.TryGetProperty("enum", out var enumElement)) { return null; } if (enumElement.ValueKind != JsonValueKind.Array) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as an array.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var allowedValues = new List(); foreach (var item in enumElement.EnumerateArray()) { allowedValues.Add( new YamlConfigAllowedValue( BuildComparableConstantValue(tableName, schemaPath, propertyPath, keywordName, item, schemaNode), BuildEnumDisplayValue(item))); } return allowedValues; } /// /// 读取一个 enum 候选值的展示文本。 /// 这里直接保留原始 JSON 字面量,避免字符串引号、对象字段顺序或数组结构在诊断中被二次格式化后丢失上下文。 /// /// Schema 中的单个枚举值。 /// 适合放入诊断消息的展示文本。 private static string BuildEnumDisplayValue(JsonElement element) { return element.GetRawText(); } /// /// 解析 const,并把 schema 常量预归一化成与运行时 YAML 相同的稳定比较键。 /// 这样运行时只需要复用现有递归比较逻辑,而不必在每次加载时重新解释 JSON 常量。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 已解析的 schema 节点。 /// 常量约束模型;未声明时返回空。 private static YamlConfigConstantValue? ParseConstantValue( string tableName, string schemaPath, string propertyPath, JsonElement element, YamlConfigSchemaNode schemaNode) { if (!element.TryGetProperty("const", out var constantElement)) { return null; } return new YamlConfigConstantValue( BuildComparableConstantValue(tableName, schemaPath, propertyPath, "const", constantElement, schemaNode), constantElement.GetRawText()); } /// /// 把 schema 中的 const JSON 值转换成与 YAML 运行时一致的比较键。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 关键字名称。 /// 常量 JSON 值。 /// 目标 schema 节点。 /// 可稳定比较的归一化键。 private static string BuildComparableConstantValue( string tableName, string schemaPath, string propertyPath, string keywordName, JsonElement element, YamlConfigSchemaNode schemaNode) { return schemaNode.NodeType switch { YamlConfigSchemaPropertyType.Object => BuildComparableConstantObjectValue( tableName, schemaPath, propertyPath, keywordName, element, schemaNode), YamlConfigSchemaPropertyType.Array => BuildComparableConstantArrayValue( tableName, schemaPath, propertyPath, keywordName, element, schemaNode), YamlConfigSchemaPropertyType.Integer => BuildComparableConstantScalarValue( tableName, schemaPath, propertyPath, keywordName, element, schemaNode), YamlConfigSchemaPropertyType.Number => BuildComparableConstantScalarValue( tableName, schemaPath, propertyPath, keywordName, element, schemaNode), YamlConfigSchemaPropertyType.Boolean => BuildComparableConstantScalarValue( tableName, schemaPath, propertyPath, keywordName, element, schemaNode), YamlConfigSchemaPropertyType.String => BuildComparableConstantScalarValue( tableName, schemaPath, propertyPath, keywordName, element, schemaNode), _ => throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.") }; } /// /// 构建对象常量的稳定比较键。 /// 这里同样忽略 JSON 对象字段顺序,避免 schema 文本格式影响常量比较结果。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 关键字名称。 /// 常量 JSON 值。 /// 对象 schema 节点。 /// 对象常量的可比较键。 private static string BuildComparableConstantObjectValue( string tableName, string schemaPath, string propertyPath, string keywordName, JsonElement element, YamlConfigSchemaNode schemaNode) { if (element.ValueKind != JsonValueKind.Object) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only object values are compatible with schema type '{GetTypeName(schemaNode.NodeType)}'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var properties = schemaNode.Properties ?? throw new InvalidOperationException("Object schema nodes must expose declared properties."); var objectEntries = new List>(); foreach (var property in element.EnumerateObject()) { if (!properties.TryGetValue(property.Name, out var propertySchema)) { var childPath = CombineSchemaPath(propertyPath, property.Name); throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but nested property '{childPath}' is not declared in the object schema.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(childPath)); } objectEntries.Add( new KeyValuePair( property.Name, BuildComparableConstantValue( tableName, schemaPath, CombineSchemaPath(propertyPath, property.Name), keywordName, property.Value, propertySchema))); } objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); return string.Join( "|", objectEntries.Select(static entry => $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); } /// /// 构建数组常量的稳定比较键。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 关键字名称。 /// 常量 JSON 值。 /// 数组 schema 节点。 /// 数组常量的可比较键。 private static string BuildComparableConstantArrayValue( string tableName, string schemaPath, string propertyPath, string keywordName, JsonElement element, YamlConfigSchemaNode schemaNode) { if (element.ValueKind != JsonValueKind.Array) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only array values are compatible with schema type '{GetTypeName(schemaNode.NodeType)}'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } if (schemaNode.ItemNode is null) { throw new InvalidOperationException("Array schema nodes must expose their item schema."); } return "[" + string.Join( ",", element.EnumerateArray().Select((item, index) => { var comparableValue = BuildComparableConstantValue( tableName, schemaPath, $"{propertyPath}[{index}]", keywordName, item, schemaNode.ItemNode); return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}"; })) + "]"; } /// /// 构建标量常量的稳定比较键。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 关键字名称。 /// 常量 JSON 值。 /// 标量 schema 节点。 /// 标量常量的可比较键。 private static string BuildComparableConstantScalarValue( string tableName, string schemaPath, string propertyPath, string keywordName, JsonElement element, YamlConfigSchemaNode schemaNode) { var normalizedValue = NormalizeKeywordScalarValue( tableName, schemaPath, propertyPath, keywordName, schemaNode.NodeType, element); return $"{schemaNode.NodeType}:{normalizedValue.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedValue}"; } /// /// 解析标量字段支持的范围、长度与模式约束。 /// 当前共享子集支持: /// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`, /// 以及 `string` 上的 `minLength/maxLength/pattern/format`。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 标量类型。 /// 解析后的约束模型;未声明时返回空。 private static YamlConfigScalarConstraints? ParseScalarConstraints( string tableName, string schemaPath, string propertyPath, JsonElement element, YamlConfigSchemaPropertyType nodeType) { var minimum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minimum"); var maximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maximum"); var exclusiveMinimum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMinimum"); var exclusiveMaximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMaximum"); var multipleOf = TryParseMultipleOfConstraint(tableName, schemaPath, propertyPath, element, nodeType); var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength"); var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength"); var pattern = TryParsePatternConstraint(tableName, schemaPath, propertyPath, element, nodeType); var formatConstraint = TryParseFormatConstraint(tableName, schemaPath, propertyPath, element, nodeType); if (minimum.HasValue && maximum.HasValue && minimum.Value > maximum.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minimum' greater than 'maximum'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } ValidateNumericConstraintRange( tableName, schemaPath, propertyPath, minimum, maximum, exclusiveMinimum, exclusiveMaximum); if (minLength.HasValue && maxLength.HasValue && minLength.Value > maxLength.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minLength' greater than 'maxLength'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var numericConstraints = CreateNumericScalarConstraints( minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf); var stringConstraints = CreateStringScalarConstraints( minLength, maxLength, pattern, formatConstraint); return numericConstraints is null && stringConstraints is null ? null : new YamlConfigScalarConstraints(numericConstraints, stringConstraints); } /// /// 解析数组节点支持的元素数量、去重与 contains 匹配数量约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 数组字段路径。 /// Schema 节点。 /// 数组约束模型;未声明时返回空。 private static YamlConfigArrayConstraints? ParseArrayConstraints( string tableName, string schemaPath, string propertyPath, JsonElement element) { var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems"); var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems"); var uniqueItems = TryParseUniqueItemsConstraint(tableName, schemaPath, propertyPath, element); var containsConstraints = ParseArrayContainsConstraints(tableName, schemaPath, propertyPath, element); if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minItems' greater than 'maxItems'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return !minItems.HasValue && !maxItems.HasValue && !uniqueItems && containsConstraints is null ? null : new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems, containsConstraints); } /// /// 解析数组节点声明的 contains 约束及其匹配数量边界。 /// 运行时会把 contains 解析成独立的 schema 子树,后续逐项复用同一套递归校验逻辑判断“是否匹配”。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 数组字段路径。 /// Schema 节点。 /// 数组 contains 约束模型;未声明时返回空。 private static YamlConfigArrayContainsConstraints? ParseArrayContainsConstraints( string tableName, string schemaPath, string propertyPath, JsonElement element) { var minContains = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minContains"); var maxContains = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxContains"); if (!element.TryGetProperty("contains", out var containsElement)) { if (minContains.HasValue || maxContains.HasValue) { var keywordName = minContains.HasValue ? "minContains" : "maxContains"; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares '{keywordName}' without a companion 'contains' schema.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return null; } if (containsElement.ValueKind != JsonValueKind.Object) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'contains' as an object-valued schema.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var containsNode = ParseNode(tableName, schemaPath, $"{propertyPath}[contains]", containsElement); if (containsNode.NodeType == YamlConfigSchemaPropertyType.Array) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array 'contains' schemas.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var effectiveMinContains = minContains ?? 1; if (maxContains.HasValue && effectiveMinContains > maxContains.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minContains' greater than 'maxContains'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return new YamlConfigArrayContainsConstraints(containsNode, minContains, maxContains); } /// /// 读取数值区间约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 字段类型。 /// 关键字名称。 /// 数值约束;未声明时返回空。 private static double? TryParseNumericConstraint( string tableName, string schemaPath, string propertyPath, JsonElement element, YamlConfigSchemaPropertyType nodeType, string keywordName) { if (!element.TryGetProperty(keywordName, out var constraintElement)) { return null; } if (nodeType != YamlConfigSchemaPropertyType.Integer && nodeType != YamlConfigSchemaPropertyType.Number) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only 'integer' and 'number' scalar types support numeric range constraints.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } if (constraintElement.ValueKind != JsonValueKind.Number || !constraintElement.TryGetDouble(out var constraintValue) || double.IsNaN(constraintValue) || double.IsInfinity(constraintValue)) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a finite number.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return constraintValue; } /// /// 读取 multipleOf 约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 字段类型。 /// 步进约束;未声明时返回空。 private static double? TryParseMultipleOfConstraint( string tableName, string schemaPath, string propertyPath, JsonElement element, YamlConfigSchemaPropertyType nodeType) { var multipleOf = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "multipleOf"); if (!multipleOf.HasValue) { return null; } if (multipleOf.Value <= 0d) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'multipleOf' as a positive finite number.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return multipleOf; } /// /// 读取字符串长度约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 字段类型。 /// 关键字名称。 /// 长度约束;未声明时返回空。 private static int? TryParseLengthConstraint( string tableName, string schemaPath, string propertyPath, JsonElement element, YamlConfigSchemaPropertyType nodeType, string keywordName) { if (!element.TryGetProperty(keywordName, out var constraintElement)) { return null; } if (nodeType != YamlConfigSchemaPropertyType.String) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only 'string' scalar types support length constraints.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } if (constraintElement.ValueKind != JsonValueKind.Number || !constraintElement.TryGetInt32(out var constraintValue) || constraintValue < 0) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return constraintValue; } /// /// 读取字符串正则约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 字段类型。 /// 正则模式;未声明时返回空。 private static string? TryParsePatternConstraint( string tableName, string schemaPath, string propertyPath, JsonElement element, YamlConfigSchemaPropertyType nodeType) { if (!element.TryGetProperty("pattern", out var patternElement)) { return null; } if (nodeType != YamlConfigSchemaPropertyType.String) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'pattern', but only 'string' scalar types support regular-expression constraints.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } if (patternElement.ValueKind != JsonValueKind.String) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'pattern' as a string.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var pattern = patternElement.GetString() ?? string.Empty; try { _ = new Regex(pattern, SupportedPatternRegexOptions, SupportedFormatRegexTimeout); } catch (ArgumentException exception) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares an invalid 'pattern' regular expression.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath), rawValue: pattern, innerException: exception); } return pattern; } /// /// 读取字符串 format 约束。 /// 运行时只接受当前三端共享并已验证收益的稳定子集,避免把开放格式名误当成“全部支持”。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 字段类型。 /// 归一化后的 format 约束;未声明时返回空。 private static YamlConfigStringFormatConstraint? TryParseFormatConstraint( string tableName, string schemaPath, string propertyPath, JsonElement element, YamlConfigSchemaPropertyType nodeType) { if (!element.TryGetProperty("format", out var formatElement)) { return null; } if (nodeType != YamlConfigSchemaPropertyType.String) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'format', but only 'string' scalar types support string formats.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } if (formatElement.ValueKind != JsonValueKind.String) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'format' as a string.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var formatName = formatElement.GetString() ?? string.Empty; if (TryMapSupportedStringFormat(formatName, out var kind)) { return new YamlConfigStringFormatConstraint(formatName, kind); } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares unsupported string format '{formatName}'. Supported formats are {SupportedStringFormatNames}.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath), rawValue: formatName); } /// /// 将 schema 原文中的 format 名称映射为运行时共享枚举。 /// 映射阶段统一使用大小写敏感比较,避免同一 schema 在不同环境下被“宽松容错”解释成不同语义。 /// /// schema 原始 format 名称。 /// 解析成功时输出归一化枚举。 /// 当前格式是否属于共享支持子集。 private static bool TryMapSupportedStringFormat( string formatName, out YamlConfigStringFormatKind kind) { switch (formatName) { case "date": kind = YamlConfigStringFormatKind.Date; return true; case "date-time": kind = YamlConfigStringFormatKind.DateTime; return true; case "duration": kind = YamlConfigStringFormatKind.Duration; return true; case "email": kind = YamlConfigStringFormatKind.Email; return true; case "time": kind = YamlConfigStringFormatKind.Time; return true; case "uri": kind = YamlConfigStringFormatKind.Uri; return true; case "uuid": kind = YamlConfigStringFormatKind.Uuid; return true; default: kind = default; return false; } } /// /// 读取数组元素数量约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 关键字名称。 /// 数组元素数量约束;未声明时返回空。 private static int? TryParseArrayLengthConstraint( string tableName, string schemaPath, string propertyPath, JsonElement element, string keywordName) { if (!element.TryGetProperty(keywordName, out var constraintElement)) { return null; } if (constraintElement.ValueKind != JsonValueKind.Number || !constraintElement.TryGetInt32(out var constraintValue) || constraintValue < 0) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return constraintValue; } /// /// 读取数组去重约束。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 是否启用 uniqueItems private static bool TryParseUniqueItemsConstraint( string tableName, string schemaPath, string propertyPath, JsonElement element) { if (!element.TryGetProperty("uniqueItems", out var constraintElement)) { return false; } if (constraintElement.ValueKind != JsonValueKind.True && constraintElement.ValueKind != JsonValueKind.False) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'uniqueItems' as a boolean.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return constraintElement.GetBoolean(); } /// /// 校验数值上下界组合不会形成空区间。 /// 这里把闭区间与开区间统一折算为最强边界,避免 schema 进入“无任何合法值”的状态。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 闭区间最小值。 /// 闭区间最大值。 /// 开区间最小值。 /// 开区间最大值。 private static void ValidateNumericConstraintRange( string tableName, string schemaPath, string propertyPath, double? minimum, double? maximum, double? exclusiveMinimum, double? exclusiveMaximum) { var hasLowerBound = false; var lowerBound = double.MinValue; var isLowerBoundExclusive = false; if (minimum.HasValue) { hasLowerBound = true; lowerBound = minimum.Value; } if (exclusiveMinimum.HasValue && (!hasLowerBound || exclusiveMinimum.Value > lowerBound || (exclusiveMinimum.Value.Equals(lowerBound) && !isLowerBoundExclusive))) { hasLowerBound = true; lowerBound = exclusiveMinimum.Value; isLowerBoundExclusive = true; } var hasUpperBound = false; var upperBound = double.MaxValue; var isUpperBoundExclusive = false; if (maximum.HasValue) { hasUpperBound = true; upperBound = maximum.Value; } if (exclusiveMaximum.HasValue && (!hasUpperBound || exclusiveMaximum.Value < upperBound || (exclusiveMaximum.Value.Equals(upperBound) && !isUpperBoundExclusive))) { hasUpperBound = true; upperBound = exclusiveMaximum.Value; isUpperBoundExclusive = true; } if (!hasLowerBound || !hasUpperBound) { return; } if (lowerBound > upperBound || (lowerBound.Equals(upperBound) && (isLowerBoundExclusive || isUpperBoundExclusive))) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' declares numeric constraints that do not leave any valid value range.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } } /// /// 校验标量值是否满足范围与长度约束。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径。 /// 原始 YAML 标量值。 /// 归一化后的比较值。 /// 标量 schema 节点。 private static void ValidateScalarConstraints( string tableName, string yamlPath, string displayPath, string rawValue, string normalizedValue, YamlConfigSchemaNode schemaNode) { var constraints = schemaNode.Constraints; if (constraints is null) { return; } switch (schemaNode.NodeType) { case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Number: ValidateNumericScalarConstraints( tableName, yamlPath, displayPath, rawValue, normalizedValue, schemaNode, constraints.NumericConstraints); return; case YamlConfigSchemaPropertyType.String: ValidateStringScalarConstraints( tableName, yamlPath, displayPath, rawValue, schemaNode, constraints.StringConstraints); return; default: throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.UnexpectedFailure, tableName, $"Property '{displayPath}' in config file '{yamlPath}' resolved unsupported constraint host type '{schemaNode.NodeType}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: schemaNode.NodeType.ToString()); } } /// /// 校验节点值是否命中声明的 enum 集合。 /// 该实现与 const 共享同一套可比较键,保证对象字段顺序、数组顺序和数值归一化语义稳定一致。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径;根节点时为空。 /// 当前 YAML 节点。 /// 对应的 schema 节点。 private static void ValidateAllowedValues( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode) { var allowedValues = schemaNode.AllowedValues; if (allowedValues is null || allowedValues.Count == 0) { return; } var comparableValue = BuildComparableNodeValue(node, schemaNode); if (allowedValues.Any(allowedValue => string.Equals(allowedValue.ComparableValue, comparableValue, StringComparison.Ordinal))) { return; } var subject = string.IsNullOrWhiteSpace(displayPath) ? "Root object" : $"Property '{displayPath}'"; var displayValues = string.Join(", ", allowedValues.Select(static allowedValue => allowedValue.DisplayValue)); throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.EnumValueNotAllowed, tableName, $"{subject} in config file '{yamlPath}' must be one of [{displayValues}].", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode), detail: $"Allowed values: {displayValues}."); } /// /// 校验节点值是否满足 const 约束。 /// 该检查复用与 uniqueItems 相同的稳定比较键,保证对象字段顺序、数字字面量和布尔大小写不会造成伪差异。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径;根节点时为空。 /// 当前 YAML 节点。 /// 对应的 schema 节点。 private static void ValidateConstantValue( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode) { var constantValue = schemaNode.ConstantValue; if (constantValue is null) { return; } var comparableValue = BuildComparableNodeValue(node, schemaNode); if (string.Equals(comparableValue, constantValue.ComparableValue, StringComparison.Ordinal)) { return; } var subject = string.IsNullOrWhiteSpace(displayPath) ? "Root object" : $"Property '{displayPath}'"; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"{subject} in config file '{yamlPath}' must match constant value {constantValue.DisplayValue}.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode), detail: $"Required constant value: {constantValue.DisplayValue}."); } /// /// 根据已读取的数值关键字创建数值约束对象。 /// 该分组让调用方不必再维护一个超过 Sonar 默认阈值的长参数构造函数。 /// /// 最小值约束。 /// 最大值约束。 /// 开区间最小值约束。 /// 开区间最大值约束。 /// 数值步进约束。 /// 数值约束对象;未声明任何数值约束时返回空。 private static YamlConfigNumericConstraints? CreateNumericScalarConstraints( double? minimum, double? maximum, double? exclusiveMinimum, double? exclusiveMaximum, double? multipleOf) { return !minimum.HasValue && !maximum.HasValue && !exclusiveMinimum.HasValue && !exclusiveMaximum.HasValue && !multipleOf.HasValue ? null : new YamlConfigNumericConstraints( minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf); } /// /// 根据已读取的字符串关键字创建字符串约束对象。 /// 正则会在 schema 解析阶段预编译,字符串 format 也会先归一化成共享枚举, /// 避免每次校验都重新解释 schema 原文。 /// /// 最小长度约束。 /// 最大长度约束。 /// 正则模式约束。 /// 字符串 format 约束。 /// 字符串约束对象;未声明任何字符串约束时返回空。 private static YamlConfigStringConstraints? CreateStringScalarConstraints( int? minLength, int? maxLength, string? pattern, YamlConfigStringFormatConstraint? formatConstraint) { return !minLength.HasValue && !maxLength.HasValue && pattern is null && formatConstraint is null ? null : new YamlConfigStringConstraints( minLength, maxLength, pattern, pattern is null ? null : new Regex( pattern, SupportedPatternRegexOptions, SupportedFormatRegexTimeout), formatConstraint); } /// /// 校验数值标量的区间与步进约束。 /// 该方法把解析失败、闭区间、开区间和步进诊断集中到数值路径,避免主调度方法继续增长。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径。 /// 原始 YAML 标量值。 /// 归一化后的比较值。 /// 标量 schema 节点。 /// 数值约束对象。 private static void ValidateNumericScalarConstraints( string tableName, string yamlPath, string displayPath, string rawValue, string normalizedValue, YamlConfigSchemaNode schemaNode, YamlConfigNumericConstraints? constraints) { if (constraints is null) { return; } var numericValue = ParseComparableNumericValue( tableName, yamlPath, displayPath, rawValue, normalizedValue, schemaNode); ValidateNumericLowerBounds( tableName, yamlPath, displayPath, rawValue, schemaNode, constraints, numericValue); ValidateNumericUpperBounds( tableName, yamlPath, displayPath, rawValue, schemaNode, constraints, numericValue); ValidateNumericMultipleOfConstraint( tableName, yamlPath, displayPath, rawValue, normalizedValue, schemaNode, constraints, numericValue); } /// /// 校验数值的最小值与开区间下界。 /// private static void ValidateNumericLowerBounds( string tableName, string yamlPath, string displayPath, string rawValue, YamlConfigSchemaNode schemaNode, YamlConfigNumericConstraints constraints, double numericValue) { if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be greater than or equal to {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}."); } if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be greater than {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}."); } } /// /// 校验数值的最大值与开区间上界。 /// private static void ValidateNumericUpperBounds( string tableName, string yamlPath, string displayPath, string rawValue, YamlConfigSchemaNode schemaNode, YamlConfigNumericConstraints constraints, double numericValue) { if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be less than or equal to {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}."); } if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be less than {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}."); } } /// /// 校验数值是否满足 multipleOf 步进约束。 /// private static void ValidateNumericMultipleOfConstraint( string tableName, string yamlPath, string displayPath, string rawValue, string normalizedValue, YamlConfigSchemaNode schemaNode, YamlConfigNumericConstraints constraints, double numericValue) { if (!constraints.MultipleOf.HasValue || IsMultipleOf(normalizedValue, numericValue, constraints.MultipleOf.Value)) { return; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be a multiple of {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}."); } /// /// 将归一化后的数值文本还原为双精度值,用于统一后续区间比较。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径。 /// 原始 YAML 标量值。 /// 归一化后的比较值。 /// 标量 schema 节点。 /// 可比较的双精度值。 private static double ParseComparableNumericValue( string tableName, string yamlPath, string displayPath, string rawValue, string normalizedValue, YamlConfigSchemaNode schemaNode) { if (double.TryParse( normalizedValue, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var numericValue)) { return numericValue; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.UnexpectedFailure, tableName, $"Property '{displayPath}' in config file '{yamlPath}' could not be normalized into a comparable numeric value.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue); } /// /// 校验字符串标量的长度、模式与 format 约束。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径。 /// 原始 YAML 标量值。 /// 标量 schema 节点。 /// 字符串约束对象。 private static void ValidateStringScalarConstraints( string tableName, string yamlPath, string displayPath, string rawValue, YamlConfigSchemaNode schemaNode, YamlConfigStringConstraints? constraints) { if (constraints is null) { return; } var stringLength = rawValue.Length; if (constraints.MinLength.HasValue && stringLength < constraints.MinLength.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be at least {constraints.MinLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Minimum length: {constraints.MinLength.Value}."); } if (constraints.MaxLength.HasValue && stringLength > constraints.MaxLength.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must be at most {constraints.MaxLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Maximum length: {constraints.MaxLength.Value}."); } if (constraints.PatternRegex is not null && !constraints.PatternRegex.IsMatch(rawValue)) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must match regular expression '{constraints.Pattern}', but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Expected pattern: {constraints.Pattern}."); } if (constraints.FormatConstraint is not null && !MatchesSupportedStringFormat(rawValue, constraints.FormatConstraint.Kind)) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must satisfy string format '{constraints.FormatConstraint.SchemaName}', but the current YAML scalar value is '{rawValue}'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Expected string format: {constraints.FormatConstraint.SchemaName}."); } } /// /// 判断一个字符串是否满足当前共享支持的 format 子集。 /// 这里只接受 Runtime / Generator / Tooling 三端都能稳定解释的格式, /// 避免把 JSON Schema 的开放格式名直接当成“全部支持”。 /// /// 待校验的字符串值。 /// 共享 format 枚举。 /// 当前字符串是否满足指定 format。 private static bool MatchesSupportedStringFormat( string value, YamlConfigStringFormatKind formatKind) { ArgumentNullException.ThrowIfNull(value); return formatKind switch { YamlConfigStringFormatKind.Date => MatchesSupportedDateFormat(value), YamlConfigStringFormatKind.DateTime => MatchesSupportedDateTimeFormat(value), YamlConfigStringFormatKind.Duration => MatchesSupportedDurationFormat(value), YamlConfigStringFormatKind.Email => SupportedEmailFormatRegex.IsMatch(value), YamlConfigStringFormatKind.Time => MatchesSupportedTimeFormat(value), YamlConfigStringFormatKind.Uri => MatchesSupportedUriFormat(value), YamlConfigStringFormatKind.Uuid => Guid.TryParseExact(value, "D", out _), _ => false }; } /// /// 判断字符串是否满足共享支持的 date 格式。 /// 这里固定采用 yyyy-MM-dd,避免文化区域或宽松解析导致工具与运行时漂移。 /// /// 待校验的字符串值。 /// 当前值是否是合法日期。 private static bool MatchesSupportedDateFormat(string value) { return SupportedDateFormatRegex.IsMatch(value) && DateOnly.TryParseExact( value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); } /// /// 判断字符串是否满足共享支持的 date-time 格式。 /// 该格式固定要求显式时区偏移,避免消费者误把本地时区文本当成跨进程稳定配置。 /// /// 待校验的字符串值。 /// 当前值是否是合法时间戳。 private static bool MatchesSupportedDateTimeFormat(string value) { if (!SupportedDateTimeFormatRegex.IsMatch(value)) { return false; } return DateTimeOffset.TryParseExact( value, ["yyyy'-'MM'-'dd'T'HH':'mm':'ssK", "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK"], CultureInfo.InvariantCulture, DateTimeStyles.None, out _); } /// /// 判断字符串是否满足共享支持的 duration 格式。 /// 当前共享子集只接受 day-time duration:可声明 D/H/M/S,秒允许小数, /// 但拒绝 Y / M(月) / W 等依赖日历语义的部分, /// 避免不同宿主对“一个月/一年到底多长”出现解释漂移。 /// /// 待校验的字符串值。 /// 当前值是否是合法持续时间文本。 private static bool MatchesSupportedDurationFormat(string value) { var match = SupportedDurationFormatRegex.Match(value); if (!match.Success) { return false; } var hasDayComponent = match.Groups["days"].Success; var hasHourComponent = match.Groups["hours"].Success; var hasMinuteComponent = match.Groups["minutes"].Success; var hasSecondComponent = match.Groups["seconds"].Success; var hasAnyComponent = hasDayComponent || hasHourComponent || hasMinuteComponent || hasSecondComponent; if (!hasAnyComponent) { return false; } var hasTimeSection = value.Contains('T', StringComparison.Ordinal); if (hasTimeSection && !hasHourComponent && !hasMinuteComponent && !hasSecondComponent) { return false; } return true; } /// /// 判断字符串是否满足共享支持的 time 格式。 /// 该格式固定要求显式时区偏移,并只接受 24 小时制的合法时分秒文本, /// 避免不同宿主对“time-only + offset”解析默认日期或时区时产生漂移。 /// /// 待校验的字符串值。 /// 当前值是否是合法时间文本。 private static bool MatchesSupportedTimeFormat(string value) { var match = SupportedTimeFormatRegex.Match(value); if (!match.Success) { return false; } var hour = int.Parse(match.Groups["hour"].Value, CultureInfo.InvariantCulture); var minute = int.Parse(match.Groups["minute"].Value, CultureInfo.InvariantCulture); var second = int.Parse(match.Groups["second"].Value, CultureInfo.InvariantCulture); if (hour > 23 || minute > 59 || second > 59) { return false; } var offset = match.Groups["offset"].Value; if (string.Equals(offset, "Z", StringComparison.Ordinal)) { return true; } var offsetHour = int.Parse(offset.AsSpan(1, 2), CultureInfo.InvariantCulture); var offsetMinute = int.Parse(offset.AsSpan(4, 2), CultureInfo.InvariantCulture); return offsetHour <= 23 && offsetMinute <= 59; } /// /// 判断字符串是否满足共享支持的 uri 格式。 /// 这里要求输入显式包含 scheme,避免把普通路径意外解释成平台相关的绝对 URI。 /// /// 待校验的字符串值。 /// 当前值是否是合法绝对 URI。 private static bool MatchesSupportedUriFormat(string value) { return SupportedUriSchemeRegex.IsMatch(value) && Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.IsAbsoluteUri; } /// /// 校验数组值是否满足元素数量约束。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径。 /// 当前数组元素数量。 /// 数组 schema 节点。 private static void ValidateArrayConstraints( string tableName, string yamlPath, string displayPath, int itemCount, YamlConfigSchemaNode schemaNode) { var constraints = schemaNode.ArrayConstraints; if (constraints is null) { return; } if (constraints.MinItems.HasValue && itemCount < constraints.MinItems.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must contain at least {constraints.MinItems.Value} items, but the current YAML sequence contains {itemCount}.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: itemCount.ToString(CultureInfo.InvariantCulture), detail: $"Minimum item count: {constraints.MinItems.Value}."); } if (constraints.MaxItems.HasValue && itemCount > constraints.MaxItems.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must contain at most {constraints.MaxItems.Value} items, but the current YAML sequence contains {itemCount}.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: itemCount.ToString(CultureInfo.InvariantCulture), detail: $"Maximum item count: {constraints.MaxItems.Value}."); } } /// /// 校验数组是否满足去重约束。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径。 /// 实际数组节点。 /// 数组 schema 节点。 private static void ValidateArrayUniqueItemsConstraint( string tableName, string yamlPath, string displayPath, YamlSequenceNode sequenceNode, YamlConfigSchemaNode schemaNode) { var constraints = schemaNode.ArrayConstraints; if (constraints is null || !constraints.UniqueItems || schemaNode.ItemNode is null) { return; } // The canonical item key uses schema-aware normalization so object key order, // scalar quoting, and numeric formatting do not accidentally bypass uniqueItems. Dictionary seenItems = new(StringComparer.Ordinal); for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) { var itemNode = sequenceNode.Children[itemIndex]; var comparableValue = BuildComparableNodeValue(itemNode, schemaNode.ItemNode); if (seenItems.TryGetValue(comparableValue, out var existingIndex)) { var itemPath = $"{displayPath}[{itemIndex}]"; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' requires unique array items, but item '{itemPath}' duplicates '{displayPath}[{existingIndex}]'.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: itemPath, rawValue: DescribeYamlNodeForDiagnostics(itemNode, schemaNode.ItemNode), detail: "The schema declares uniqueItems = true."); } seenItems.Add(comparableValue, itemIndex); } } /// /// 校验数组是否满足 contains 声明的匹配数量边界。 /// 该实现会对每个数组项复用同一套递归校验逻辑做“非抛出式匹配”,避免 contains 与主校验链各自维护不同的 schema 解释规则。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 字段路径。 /// 实际数组节点。 /// 数组 schema 节点。 /// 匹配成功的 contains 子树所声明的跨表引用收集器。 private static void ValidateArrayContainsConstraints( string tableName, string yamlPath, string displayPath, YamlSequenceNode sequenceNode, YamlConfigSchemaNode schemaNode, ICollection? references) { var containsConstraints = schemaNode.ArrayConstraints?.ContainsConstraints; if (containsConstraints is null) { return; } var matchingCount = CountMatchingContainsItems( tableName, yamlPath, displayPath, sequenceNode, containsConstraints.ContainsNode, references); var rawValue = matchingCount.ToString(CultureInfo.InvariantCulture); var requiredMinContains = containsConstraints.MinContains ?? 1; if (matchingCount < requiredMinContains) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must contain at least {requiredMinContains} items matching the 'contains' schema, but the current YAML sequence contains {matchingCount}.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Minimum matching contains count: {requiredMinContains}."); } if (containsConstraints.MaxContains.HasValue && matchingCount > containsConstraints.MaxContains.Value) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"Property '{displayPath}' in config file '{yamlPath}' must contain at most {containsConstraints.MaxContains.Value} items matching the 'contains' schema, but the current YAML sequence contains {matchingCount}.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: rawValue, detail: $"Maximum matching contains count: {containsConstraints.MaxContains.Value}."); } } /// /// 统计当前数组中有多少元素满足 contains 子 schema。 /// 非预期内部错误会继续抛出,只有正常的 schema 不匹配才会被当成“当前元素不计数”。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 数组字段路径。 /// 实际数组节点。 /// contains 子 schema。 /// 匹配成功元素的可选跨表引用收集器。 /// 匹配 contains 子 schema 的元素数量。 private static int CountMatchingContainsItems( string tableName, string yamlPath, string displayPath, YamlSequenceNode sequenceNode, YamlConfigSchemaNode containsNode, ICollection? references) { var matchingCount = 0; for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) { if (TryMatchSchemaNode( tableName, yamlPath, $"{displayPath}[{itemIndex}]", sequenceNode.Children[itemIndex], containsNode, references, allowUnknownObjectProperties: true)) { matchingCount++; } } return matchingCount; } /// /// 判断当前 YAML 节点是否满足给定 schema 子树。 /// contains / not 都通过该路径复用主校验逻辑,因此普通约束失败会返回 , /// 但内部意外状态仍会继续抛出。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 当前节点路径。 /// 实际 YAML 节点。 /// 要试匹配的 schema 子树。 /// 当前节点匹配成功后要写回的可选跨表引用收集器。 /// 对象试匹配时是否允许额外字段。 /// 当前节点是否匹配指定 schema 子树。 private static bool TryMatchSchemaNode( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, ICollection? references, bool allowUnknownObjectProperties) { // 约束试匹配不能把失败路径的引用泄漏给外层,但匹配成功的分支仍需要把引用写回, // 这样 contains / not 等内联 schema 才能与主校验链复用同一套递归解释规则。 List? matchedReferences = references is null ? null : new(); try { ValidateNode( tableName, yamlPath, displayPath, node, schemaNode, matchedReferences, allowUnknownObjectProperties); if (references is not null && matchedReferences is not null) { AddUniqueReferenceUsages(references, matchedReferences); } return true; } catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind != ConfigLoadFailureKind.UnexpectedFailure) { return false; } } /// /// 将试匹配分支采集到的引用回写到外层集合,并按结构化标识去重。 /// /// 外层引用集合。 /// 当前成功匹配分支采集到的引用。 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 会沿用主校验链的严格对象语义,避免把“声明属性子集”误当成完整命中。 /// /// 所属配置表名称。 /// YAML 文件路径。 /// 当前字段路径。 /// 当前 YAML 节点。 /// 当前 schema 节点。 private static void ValidateNegatedSchemaConstraint( string tableName, string yamlPath, string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode) { if (schemaNode.NegatedSchemaNode is null || !TryMatchSchemaNode( tableName, yamlPath, displayPath, node, schemaNode.NegatedSchemaNode, references: null, allowUnknownObjectProperties: false)) { return; } var subject = string.IsNullOrWhiteSpace(displayPath) ? "Root object" : $"Property '{displayPath}'"; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, $"{subject} in config file '{yamlPath}' must not match the 'not' schema.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode.NegatedSchemaNode), detail: "The current YAML value matches the forbidden 'not' schema."); } /// /// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。 /// 该键同时服务于 uniqueItemsconst, /// 因此要忽略对象字段顺序和字符串引号形式。 /// /// YAML 节点。 /// 对应 schema 节点。 /// 可稳定比较的归一化键。 private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode) { return schemaNode.NodeType switch { YamlConfigSchemaPropertyType.Object => BuildComparableObjectValue(node, schemaNode), YamlConfigSchemaPropertyType.Array => BuildComparableArrayValue(node, schemaNode), YamlConfigSchemaPropertyType.Integer => BuildComparableScalarValue(node, schemaNode), YamlConfigSchemaPropertyType.Number => BuildComparableScalarValue(node, schemaNode), YamlConfigSchemaPropertyType.Boolean => BuildComparableScalarValue(node, schemaNode), YamlConfigSchemaPropertyType.String => BuildComparableScalarValue(node, schemaNode), _ => throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.") }; } /// /// 构建对象节点的可比较键。 /// 对象字段会先按属性名排序,避免 YAML 原始字段顺序影响 uniqueItems 的等价关系。 /// /// YAML 节点。 /// 对象 schema 节点。 /// 对象节点的稳定比较键。 private static string BuildComparableObjectValue(YamlNode node, YamlConfigSchemaNode schemaNode) { if (node is not YamlMappingNode mappingNode) { throw new InvalidOperationException("Validated object nodes must be YAML mappings."); } var properties = schemaNode.Properties ?? throw new InvalidOperationException( "Validated object nodes must expose declared properties."); var objectEntries = new List>(mappingNode.Children.Count); foreach (var entry in mappingNode.Children) { if (entry.Key is not YamlScalarNode keyNode || keyNode.Value is null || !properties.TryGetValue(keyNode.Value, out var propertySchema)) { throw new InvalidOperationException("Validated object nodes must use declared scalar property names."); } objectEntries.Add( new KeyValuePair( keyNode.Value, BuildComparableNodeValue(entry.Value, propertySchema))); } objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); return string.Join( "|", objectEntries.Select(static entry => $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); } /// /// 构建数组节点的可比较键。 /// 数组仍保留元素顺序,因为 uniqueItems 只忽略对象字段顺序,不忽略数组顺序。 /// /// YAML 节点。 /// 数组 schema 节点。 /// 数组节点的稳定比较键。 private static string BuildComparableArrayValue(YamlNode node, YamlConfigSchemaNode schemaNode) { if (node is not YamlSequenceNode sequenceNode || schemaNode.ItemNode is null) { throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema."); } return "[" + string.Join( ",", sequenceNode.Children.Select(item => { var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode); return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}"; })) + "]"; } /// /// 构建标量节点的可比较键。 /// 标量会沿用与 enum / 引用校验一致的归一化规则,避免数字格式和引号形式导致伪差异。 /// /// YAML 节点。 /// 标量 schema 节点。 /// 标量节点的稳定比较键。 private static string BuildComparableScalarValue(YamlNode node, YamlConfigSchemaNode schemaNode) { if (node is not YamlScalarNode scalarNode || scalarNode.Value is null) { throw new InvalidOperationException("Validated scalar nodes must be YAML scalars."); } var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value); return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}"; } /// /// 为唯一性诊断提取一个可读的节点摘要。 /// /// YAML 节点。 /// 对应 schema 节点。 /// 诊断摘要。 private static string DescribeYamlNodeForDiagnostics(YamlNode node, YamlConfigSchemaNode schemaNode) { return schemaNode.NodeType switch { YamlConfigSchemaPropertyType.Object => "{...}", YamlConfigSchemaPropertyType.Array => "[...]", _ when node is YamlScalarNode scalarNode => scalarNode.Value ?? string.Empty, _ => node.GetType().Name }; } /// /// 判断数值是否满足 multipleOf。 /// 优先按十进制字面量做精确整倍数判断, /// 以同时避免 0.1 / 0.01 这类十进制步进的伪失败和大数量级非整倍数的伪通过; /// 只有当值超出精确十进制路径时才退回双精度容差比较。 /// /// 用于数值比较的规范化 YAML 标量文本。 /// 当前值。 /// 步进约束。 /// 是否满足整倍数关系。 private static bool IsMultipleOf(string normalizedValue, double value, double divisor) { if (TryIsExactDecimalMultiple(normalizedValue, divisor, out var exactResult)) { return exactResult; } var quotient = value / divisor; var nearestInteger = Math.Round(quotient); var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient)); return Math.Abs(quotient - nearestInteger) <= tolerance; } /// /// 尝试按十进制字面量精确判断 multipleOf。 /// 该路径直接对齐 YAML / JSON 中常见的有限十进制写法, /// 避免双精度舍入把明显的非整倍数误判为合法。 /// /// 规范化后的 YAML 数值文本。 /// Schema 声明的步进约束。 /// 精确路径下的判断结果。 /// 是否成功进入精确十进制判断路径。 private static bool TryIsExactDecimalMultiple(string valueText, double divisor, out bool isMultiple) { var divisorText = divisor.ToString("R", CultureInfo.InvariantCulture); if (!TryParseExactDecimal(valueText, out var valueSignificand, out var valueScale) || !TryParseExactDecimal(divisorText, out var divisorSignificand, out var divisorScale) || divisorSignificand.IsZero) { isMultiple = false; return false; } var commonScale = Math.Max(valueScale, divisorScale); var scaledValue = ScaleDecimalSignificand(valueSignificand, valueScale, commonScale); var scaledDivisor = ScaleDecimalSignificand(divisorSignificand, divisorScale, commonScale); isMultiple = scaledValue % scaledDivisor == BigInteger.Zero; return true; } /// /// 将有限十进制或科学计数法文本拆成“整数有效数字 + 十进制位数”形式。 /// 这样可以把整倍数判断转成同一尺度下的整数取模,避免浮点误差参与计算。 /// /// 待解析的数值文本。 /// 去掉小数点后的有效数字。 /// 十进制缩放位数;原值等于 / 10^。 /// 是否成功解析为有限十进制数。 private static bool TryParseExactDecimal(string text, out BigInteger significand, out int scale) { var match = ExactDecimalPattern.Match(text); if (!match.Success) { significand = BigInteger.Zero; scale = 0; return false; } var exponentGroup = match.Groups["exponent"].Value; var exponent = 0; if (!string.IsNullOrEmpty(exponentGroup) && !int.TryParse(exponentGroup, NumberStyles.Integer, CultureInfo.InvariantCulture, out exponent)) { significand = BigInteger.Zero; scale = 0; return false; } var integerDigits = match.Groups["integer"].Value; var fractionDigits = match.Groups["fraction"].Success ? match.Groups["fraction"].Value : match.Groups["fractionOnly"].Value; var digits = string.Concat(integerDigits, fractionDigits); if (digits.Length == 0) { digits = "0"; } digits = digits.TrimStart('0'); if (digits.Length == 0) { significand = BigInteger.Zero; scale = 0; return true; } scale = checked(fractionDigits.Length - exponent); if (scale < 0) { digits = string.Concat(digits, new string('0', -scale)); scale = 0; } while (scale > 0 && digits[^1] == '0') { digits = digits[..^1]; scale--; } significand = BigInteger.Parse(digits, CultureInfo.InvariantCulture); if (string.Equals(match.Groups["sign"].Value, "-", StringComparison.Ordinal)) { significand = BigInteger.Negate(significand); } return true; } /// /// 将十进制有效数字放大到目标尺度,便于在同一量纲下执行整数取模。 /// /// 原始有效数字。 /// 当前十进制位数。 /// 目标十进制位数。 /// 放大到目标尺度后的有效数字。 private static BigInteger ScaleDecimalSignificand(BigInteger significand, int currentScale, int targetScale) { if (currentScale == targetScale) { return significand; } return significand * BigInteger.Pow(10, targetScale - currentScale); } /// /// 解析跨表引用目标表名称。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 目标表名称;未声明时返回空。 private static string? TryGetReferenceTableName( string tableName, string schemaPath, string propertyPath, JsonElement element) { if (!element.TryGetProperty("x-gframework-ref-table", out var referenceTableElement)) { return null; } if (referenceTableElement.ValueKind != JsonValueKind.String) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } var referenceTableName = referenceTableElement.GetString(); if (string.IsNullOrWhiteSpace(referenceTableName)) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } return referenceTableName; } /// /// 验证哪些 schema 类型允许声明跨表引用。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 字段类型。 /// 目标表名称。 private static void EnsureReferenceKeywordIsSupported( string tableName, string schemaPath, string propertyPath, YamlConfigSchemaPropertyType propertyType, string? referenceTableName) { if (referenceTableName == null) { return; } if (propertyType == YamlConfigSchemaPropertyType.String || propertyType == YamlConfigSchemaPropertyType.Integer) { return; } throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath), referencedTableName: referenceTableName); } /// /// 递归收集 schema 中声明的目标表名称。 /// /// 当前 schema 节点。 /// 输出集合。 private static void CollectReferencedTableNames( YamlConfigSchemaNode node, ISet referencedTableNames) { if (node.ReferenceTableName != null) { referencedTableNames.Add(node.ReferenceTableName); } if (node.Properties is not null) { foreach (var property in node.Properties.Values) { CollectReferencedTableNames(property, referencedTableNames); } } if (node.ItemNode is not null) { CollectReferencedTableNames(node.ItemNode, referencedTableNames); } var containsNode = node.ArrayConstraints?.ContainsConstraints?.ContainsNode; if (containsNode is not null) { CollectReferencedTableNames(containsNode, referencedTableNames); } var dependentSchemas = node.ObjectConstraints?.DependentSchemas; if (dependentSchemas is not null) { foreach (var dependentSchemaNode in dependentSchemas.Values) { CollectReferencedTableNames(dependentSchemaNode, referencedTableNames); } } var allOfSchemas = node.ObjectConstraints?.AllOfSchemas; if (allOfSchemas is not null) { foreach (var allOfSchemaNode in allOfSchemas) { CollectReferencedTableNames(allOfSchemaNode, referencedTableNames); } } var conditionalSchemas = node.ObjectConstraints?.ConditionalSchemas; if (conditionalSchemas is not null) { CollectReferencedTableNames(conditionalSchemas.IfSchema, referencedTableNames); if (conditionalSchemas.ThenSchema is not null) { CollectReferencedTableNames(conditionalSchemas.ThenSchema, referencedTableNames); } if (conditionalSchemas.ElseSchema is not null) { CollectReferencedTableNames(conditionalSchemas.ElseSchema, referencedTableNames); } } } /// /// 将 schema 关键字中的标量值归一化到运行时比较字符串。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 关键字名称。 /// 期望的标量类型。 /// 当前关键字值节点。 /// 归一化后的字符串值。 private static string NormalizeKeywordScalarValue( string tableName, string schemaPath, string propertyPath, string keywordName, YamlConfigSchemaPropertyType expectedType, JsonElement item) { try { return expectedType switch { YamlConfigSchemaPropertyType.String when item.ValueKind == JsonValueKind.String => item.GetString() ?? string.Empty, YamlConfigSchemaPropertyType.Integer when item.ValueKind == JsonValueKind.Number => item.GetInt64().ToString(CultureInfo.InvariantCulture), YamlConfigSchemaPropertyType.Number when item.ValueKind == JsonValueKind.Number => item.GetDouble().ToString(CultureInfo.InvariantCulture), YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.True => bool.TrueString.ToLowerInvariant(), YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.False => bool.FalseString.ToLowerInvariant(), _ => throw new InvalidOperationException() }; } catch { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, $"Property '{propertyPath}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } } /// /// 将内部路径转换为适合放入诊断对象的可选字段路径。 /// /// 内部使用的属性路径。 /// 可用于诊断的路径;根节点时返回空。 private static string? GetDiagnosticPath(string path) { return string.IsNullOrWhiteSpace(path) || string.Equals(path, "", StringComparison.Ordinal) ? null : path; } /// /// 将 YAML 标量值规范化成运行时比较格式。 /// /// 期望的标量类型。 /// 原始字符串值。 /// 归一化后的字符串。 private static string NormalizeScalarValue(YamlConfigSchemaPropertyType expectedType, string value) { return expectedType switch { YamlConfigSchemaPropertyType.String => value, YamlConfigSchemaPropertyType.Integer when long.TryParse( value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integerValue) => integerValue.ToString(CultureInfo.InvariantCulture), YamlConfigSchemaPropertyType.Number when double.TryParse( value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var numberValue) => numberValue.ToString(CultureInfo.InvariantCulture), YamlConfigSchemaPropertyType.Boolean when bool.TryParse(value, out var booleanValue) => booleanValue.ToString().ToLowerInvariant(), YamlConfigSchemaPropertyType.Integer => throw new InvalidOperationException($"Value '{value}' cannot be normalized as integer."), YamlConfigSchemaPropertyType.Number => throw new InvalidOperationException($"Value '{value}' cannot be normalized as number."), YamlConfigSchemaPropertyType.Boolean => throw new InvalidOperationException($"Value '{value}' cannot be normalized as boolean."), _ => throw new InvalidOperationException( $"Schema node type '{expectedType}' cannot be normalized as a scalar value.") }; } /// /// 获取 schema 类型的可读名称,用于错误信息。 /// /// Schema 节点类型。 /// 可读类型名。 private static string GetTypeName(YamlConfigSchemaPropertyType type) { return type switch { YamlConfigSchemaPropertyType.Integer => "integer", YamlConfigSchemaPropertyType.Number => "number", YamlConfigSchemaPropertyType.Boolean => "boolean", YamlConfigSchemaPropertyType.String => "string", YamlConfigSchemaPropertyType.Array => "array", YamlConfigSchemaPropertyType.Object => "object", _ => type.ToString() }; } /// /// 组合 schema 中的逻辑路径,便于诊断时指出深层字段。 /// /// 父级路径。 /// 当前属性名。 /// 组合后的路径。 private static string CombineSchemaPath(string parentPath, string propertyName) { return string.Equals(parentPath, "", StringComparison.Ordinal) ? propertyName : $"{parentPath}.{propertyName}"; } /// /// 组合 YAML 诊断展示路径。 /// /// 父级路径。 /// 当前属性名。 /// 组合后的路径。 private static string CombineDisplayPath(string parentPath, string propertyName) { return string.IsNullOrWhiteSpace(parentPath) ? propertyName : $"{parentPath}.{propertyName}"; } /// /// 返回当前节点上声明的“仅对象可用”关键字名称。 /// /// Schema 节点。 /// 命中的关键字名称;未命中时返回空。 private static string? TryGetObjectOnlyKeywordName(JsonElement element) { if (element.TryGetProperty("allOf", out _)) { return "allOf"; } if (element.TryGetProperty("if", out _)) { return "if"; } if (element.TryGetProperty("then", out _)) { return "then"; } return element.TryGetProperty("else", out _) ? "else" : null; } /// /// 判断当前标量是否应按字符串处理。 /// 这里显式排除 YAML 的数字、布尔和 null 标签,避免未加引号的值被当成字符串混入运行时。 /// /// YAML 标量标签。 /// 是否为字符串标量。 private static bool IsStringScalar(string tag) { if (string.IsNullOrWhiteSpace(tag)) { return true; } return !string.Equals(tag, "tag:yaml.org,2002:int", StringComparison.Ordinal) && !string.Equals(tag, "tag:yaml.org,2002:float", StringComparison.Ordinal) && !string.Equals(tag, "tag:yaml.org,2002:bool", StringComparison.Ordinal) && !string.Equals(tag, "tag:yaml.org,2002:null", StringComparison.Ordinal); } }