diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index 488c3427..29d57119 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -558,6 +558,47 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证科学计数法数值会按 number 类型被运行时接受。
+ ///
+ [Test]
+ public async Task LoadAsync_Should_Accept_Scientific_Notation_Number()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ dropRate: 1.5e10
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "dropRate"],
+ "properties": {
+ "id": { "type": "integer" },
+ "dropRate": { "type": "number" }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ await loader.LoadAsync(registry);
+
+ var table = registry.GetTable("monster");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(table.Count, Is.EqualTo(1));
+ Assert.That(table.Get(1).DropRate, Is.EqualTo(1.5e10));
+ });
+ }
+
///
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
///
@@ -864,6 +905,68 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证 uniqueItems 的归一化键不会把带分隔符的不同对象值误判为重复项。
+ ///
+ [Test]
+ public async Task LoadAsync_Should_Accept_Distinct_Object_Items_When_Comparable_Values_Contain_Separators()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ entries:
+ -
+ a: "x|1:b=string:yz"
+ -
+ a: x
+ b: yz
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "entries"],
+ "properties": {
+ "id": { "type": "integer" },
+ "entries": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "object",
+ "properties": {
+ "a": { "type": "string" },
+ "b": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable(
+ "monster",
+ "monster",
+ "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ await loader.LoadAsync(registry);
+
+ var table = registry.GetTable("monster");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(table.Count, Is.EqualTo(1));
+ Assert.That(table.Get(1).Entries.Count, Is.EqualTo(2));
+ Assert.That(table.Get(1).Entries[0].A, Is.EqualTo("x|1:b=string:yz"));
+ Assert.That(table.Get(1).Entries[1].A, Is.EqualTo("x"));
+ Assert.That(table.Get(1).Entries[1].B, Is.EqualTo("yz"));
+ });
+ }
+
///
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
///
@@ -1801,6 +1904,22 @@ public class YamlConfigLoaderTests
public int Hp { get; set; }
}
+ ///
+ /// 用于浮点数 schema 校验测试的最小怪物配置类型。
+ ///
+ private sealed class MonsterNumberConfigStub
+ {
+ ///
+ /// 获取或设置主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 获取或设置浮点掉落率。
+ ///
+ public double DropRate { get; set; }
+ }
+
///
/// 用于数组 schema 校验测试的最小怪物配置类型。
///
@@ -1880,6 +1999,22 @@ public class YamlConfigLoaderTests
public IReadOnlyList Phases { get; set; } = Array.Empty();
}
+ ///
+ /// 用于 uniqueItems 比较键碰撞回归测试的最小配置类型。
+ ///
+ private sealed class MonsterComparableEntryArrayConfigStub
+ {
+ ///
+ /// 获取或设置主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 获取或设置待比较对象数组。
+ ///
+ public List Entries { get; set; } = new();
+ }
+
///
/// 表示对象数组中的阶段元素。
///
@@ -1896,6 +2031,22 @@ public class YamlConfigLoaderTests
public string MonsterId { get; set; } = string.Empty;
}
+ ///
+ /// 表示用于比较键碰撞回归测试的对象数组元素。
+ ///
+ private sealed class ComparableEntryConfigStub
+ {
+ ///
+ /// 获取或设置字段 A。
+ ///
+ public string A { get; set; } = string.Empty;
+
+ ///
+ /// 获取或设置字段 B。
+ ///
+ public string B { get; set; } = string.Empty;
+ }
+
///
/// 用于深层跨表引用测试的怪物配置类型。
///
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index c1f85a3e..d8c09f9f 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -110,7 +110,7 @@ internal static class YamlConfigSchemaValidator
string yamlPath,
string yamlText)
{
- ValidateAndCollectReferences(tableName, schema, yamlPath, yamlText);
+ ValidateCore(tableName, schema, yamlPath, yamlText, references: null);
}
///
@@ -129,6 +129,29 @@ internal static class YamlConfigSchemaValidator
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))
{
@@ -166,9 +189,7 @@ internal static class YamlConfigSchemaValidator
schemaPath: schema.SchemaPath);
}
- var references = new List();
ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references);
- return references;
}
///
@@ -296,16 +317,7 @@ internal static class YamlConfigSchemaValidator
property.Value);
}
- return new YamlConfigSchemaNode(
- YamlConfigSchemaPropertyType.Object,
- properties,
- requiredProperties,
- itemNode: null,
- referenceTableName: null,
- allowedValues: null,
- constraints: null,
- arrayConstraints: null,
- schemaPath);
+ return YamlConfigSchemaNode.CreateObject(properties, requiredProperties, schemaPath);
}
///
@@ -365,15 +377,9 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath));
}
- return new YamlConfigSchemaNode(
- YamlConfigSchemaPropertyType.Array,
- properties: null,
- requiredProperties: null,
+ return YamlConfigSchemaNode.CreateArray(
itemNode,
- referenceTableName: null,
- allowedValues: null,
- constraints: null,
- arrayConstraints: ParseArrayConstraints(tableName, schemaPath, propertyPath, element),
+ ParseArrayConstraints(tableName, schemaPath, propertyPath, element),
schemaPath);
}
@@ -396,15 +402,11 @@ internal static class YamlConfigSchemaValidator
string? referenceTableName)
{
EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName);
- return new YamlConfigSchemaNode(
+ return YamlConfigSchemaNode.CreateScalar(
nodeType,
- properties: null,
- requiredProperties: null,
- itemNode: null,
referenceTableName,
ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"),
ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType),
- arrayConstraints: null,
schemaPath);
}
@@ -424,7 +426,7 @@ internal static class YamlConfigSchemaValidator
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection references)
+ ICollection? references)
{
switch (schemaNode.NodeType)
{
@@ -470,7 +472,7 @@ internal static class YamlConfigSchemaValidator
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection references)
+ ICollection? references)
{
if (node is not YamlMappingNode mappingNode)
{
@@ -566,7 +568,7 @@ internal static class YamlConfigSchemaValidator
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection references)
+ ICollection? references)
{
if (node is not YamlSequenceNode sequenceNode)
{
@@ -624,7 +626,7 @@ internal static class YamlConfigSchemaValidator
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection references)
+ ICollection? references)
{
if (node is not YamlScalarNode scalarNode)
{
@@ -699,7 +701,8 @@ internal static class YamlConfigSchemaValidator
ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode);
}
- if (schemaNode.ReferenceTableName != null)
+ if (schemaNode.ReferenceTableName != null &&
+ references is not null)
{
references.Add(
new YamlConfigReferenceUsage(
@@ -814,32 +817,20 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath));
}
- if (!minimum.HasValue &&
- !maximum.HasValue &&
- !exclusiveMinimum.HasValue &&
- !exclusiveMaximum.HasValue &&
- !multipleOf.HasValue &&
- !minLength.HasValue &&
- !maxLength.HasValue &&
- pattern is null)
- {
- return null;
- }
-
- return new YamlConfigScalarConstraints(
+ var numericConstraints = CreateNumericScalarConstraints(
minimum,
maximum,
exclusiveMinimum,
exclusiveMaximum,
- multipleOf,
+ multipleOf);
+ var stringConstraints = CreateStringScalarConstraints(
minLength,
maxLength,
- pattern,
- pattern is null
- ? null
- : new Regex(
- pattern,
- SupportedPatternRegexOptions));
+ pattern);
+
+ return numericConstraints is null && stringConstraints is null
+ ? null
+ : new YamlConfigScalarConstraints(numericConstraints, stringConstraints);
}
///
@@ -1242,137 +1233,24 @@ internal static class YamlConfigSchemaValidator
{
case YamlConfigSchemaPropertyType.Integer:
case YamlConfigSchemaPropertyType.Number:
- if (!double.TryParse(
- normalizedValue,
- NumberStyles.Float | NumberStyles.AllowThousands,
- CultureInfo.InvariantCulture,
- out var 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);
- }
-
- 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)}.");
- }
-
- 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)}.");
- }
-
- if (constraints.MultipleOf.HasValue &&
- !IsMultipleOf(numericValue, constraints.MultipleOf.Value))
- {
- 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)}.");
- }
-
+ ValidateNumericScalarConstraints(
+ tableName,
+ yamlPath,
+ displayPath,
+ rawValue,
+ normalizedValue,
+ schemaNode,
+ constraints.NumericConstraints);
return;
case YamlConfigSchemaPropertyType.String:
- 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}.");
- }
-
+ ValidateStringScalarConstraints(
+ tableName,
+ yamlPath,
+ displayPath,
+ rawValue,
+ schemaNode,
+ constraints.StringConstraints);
return;
default:
@@ -1387,6 +1265,265 @@ internal static class YamlConfigSchemaValidator
}
}
+ ///
+ /// 根据已读取的数值关键字创建数值约束对象。
+ /// 该分组让调用方不必再维护一个超过 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 解析阶段预编译,避免每次校验都重复实例化。
+ ///
+ /// 最小长度约束。
+ /// 最大长度约束。
+ /// 正则模式约束。
+ /// 字符串约束对象;未声明任何字符串约束时返回空。
+ private static YamlConfigStringConstraints? CreateStringScalarConstraints(
+ int? minLength,
+ int? maxLength,
+ string? pattern)
+ {
+ return !minLength.HasValue &&
+ !maxLength.HasValue &&
+ pattern is null
+ ? null
+ : new YamlConfigStringConstraints(
+ minLength,
+ maxLength,
+ pattern,
+ pattern is null
+ ? null
+ : new Regex(
+ pattern,
+ SupportedPatternRegexOptions));
+ }
+
+ ///
+ /// 校验数值标量的区间与步进约束。
+ /// 该方法把解析失败、闭区间、开区间和步进诊断集中到数值路径,避免主调度方法继续增长。
+ ///
+ /// 所属配置表名称。
+ /// 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);
+ 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)}.");
+ }
+
+ 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)}.");
+ }
+
+ if (constraints.MultipleOf.HasValue &&
+ !IsMultipleOf(numericValue, constraints.MultipleOf.Value))
+ {
+ 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);
+ }
+
+ ///
+ /// 校验字符串标量的长度与模式约束。
+ ///
+ /// 所属配置表名称。
+ /// 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}.");
+ }
+ }
+
///
/// 校验数组值是否满足元素数量约束。
///
@@ -1492,67 +1629,101 @@ internal static class YamlConfigSchemaValidator
/// 可稳定比较的归一化键。
private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{
- switch (schemaNode.NodeType)
+ return schemaNode.NodeType switch
{
- case YamlConfigSchemaPropertyType.Object:
- if (node is not YamlMappingNode mappingNode)
- {
- throw new InvalidOperationException("Validated object nodes must be YAML mappings.");
- }
+ 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}'.")
+ };
+ }
- var objectEntries = new List>(mappingNode.Children.Count);
- foreach (var entry in mappingNode.Children)
- {
- if (entry.Key is not YamlScalarNode keyNode ||
- keyNode.Value is null ||
- schemaNode.Properties is null ||
- !schemaNode.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}"));
-
- case YamlConfigSchemaPropertyType.Array:
- 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 => BuildComparableNodeValue(item, schemaNode.ItemNode))) +
- "]";
-
- case YamlConfigSchemaPropertyType.Integer:
- case YamlConfigSchemaPropertyType.Number:
- case YamlConfigSchemaPropertyType.Boolean:
- case YamlConfigSchemaPropertyType.String:
- 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}";
-
- default:
- 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}";
}
///
@@ -1899,37 +2070,98 @@ internal sealed class YamlConfigSchema
///
internal sealed class YamlConfigSchemaNode
{
+ private readonly NodeChildren _children;
+ private readonly NodeValidation _validation;
+
///
- /// 初始化一个 schema 节点描述。
+ /// 创建对象节点描述。
///
- /// 节点类型。
/// 对象属性集合。
/// 对象必填属性集合。
- /// 数组元素节点。
- /// 目标引用表名称。
- /// 标量允许值集合。
- /// 标量范围与长度约束。
- /// 数组元素数量约束。
/// 用于错误信息的 schema 文件路径提示。
- public YamlConfigSchemaNode(
- YamlConfigSchemaPropertyType nodeType,
+ /// 对象节点模型。
+ public static YamlConfigSchemaNode CreateObject(
IReadOnlyDictionary? properties,
IReadOnlyCollection? requiredProperties,
- YamlConfigSchemaNode? itemNode,
- string? referenceTableName,
- IReadOnlyCollection? allowedValues,
- YamlConfigScalarConstraints? constraints,
+ string schemaPathHint)
+ {
+ return new YamlConfigSchemaNode(
+ YamlConfigSchemaPropertyType.Object,
+ new NodeChildren(properties, requiredProperties, itemNode: null),
+ NodeValidation.None,
+ schemaPathHint);
+ }
+
+ ///
+ /// 创建数组节点描述。
+ ///
+ /// 数组元素节点。
+ /// 数组元素数量约束。
+ /// 用于错误信息的 schema 文件路径提示。
+ /// 数组节点模型。
+ public static YamlConfigSchemaNode CreateArray(
+ YamlConfigSchemaNode itemNode,
YamlConfigArrayConstraints? arrayConstraints,
string schemaPathHint)
{
+ return new YamlConfigSchemaNode(
+ YamlConfigSchemaPropertyType.Array,
+ new NodeChildren(properties: null, requiredProperties: null, itemNode),
+ new NodeValidation(
+ referenceTableName: null,
+ allowedValues: null,
+ constraints: null,
+ arrayConstraints),
+ schemaPathHint);
+ }
+
+ ///
+ /// 创建标量节点描述。
+ ///
+ /// 标量节点类型。
+ /// 目标引用表名称。
+ /// 标量允许值集合。
+ /// 标量范围与长度约束。
+ /// 用于错误信息的 schema 文件路径提示。
+ /// 标量节点模型。
+ public static YamlConfigSchemaNode CreateScalar(
+ YamlConfigSchemaPropertyType nodeType,
+ string? referenceTableName,
+ IReadOnlyCollection? allowedValues,
+ YamlConfigScalarConstraints? constraints,
+ string schemaPathHint)
+ {
+ return new YamlConfigSchemaNode(
+ nodeType,
+ NodeChildren.None,
+ new NodeValidation(
+ referenceTableName,
+ allowedValues,
+ constraints,
+ arrayConstraints: null),
+ schemaPathHint);
+ }
+
+ private YamlConfigSchemaNode(
+ YamlConfigSchemaPropertyType nodeType,
+ NodeChildren children,
+ NodeValidation validation,
+ string schemaPathHint)
+ {
+ ArgumentNullException.ThrowIfNull(children);
+ ArgumentNullException.ThrowIfNull(validation);
+ ArgumentNullException.ThrowIfNull(schemaPathHint);
+
+ _children = children;
+ _validation = validation;
NodeType = nodeType;
- Properties = properties;
- RequiredProperties = requiredProperties;
- ItemNode = itemNode;
- ReferenceTableName = referenceTableName;
- AllowedValues = allowedValues;
- Constraints = constraints;
- ArrayConstraints = arrayConstraints;
+ Properties = children.Properties;
+ RequiredProperties = children.RequiredProperties;
+ ItemNode = children.ItemNode;
+ ReferenceTableName = validation.ReferenceTableName;
+ AllowedValues = validation.AllowedValues;
+ Constraints = validation.Constraints;
+ ArrayConstraints = validation.ArrayConstraints;
SchemaPathHint = schemaPathHint;
}
@@ -1989,55 +2221,123 @@ internal sealed class YamlConfigSchemaNode
{
return new YamlConfigSchemaNode(
NodeType,
- Properties,
- RequiredProperties,
- ItemNode,
- referenceTableName,
- AllowedValues,
- Constraints,
- ArrayConstraints,
+ _children,
+ _validation.WithReferenceTable(referenceTableName),
SchemaPathHint);
}
+
+ private sealed class NodeChildren
+ {
+ public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null);
+
+ public NodeChildren(
+ IReadOnlyDictionary? properties,
+ IReadOnlyCollection? requiredProperties,
+ YamlConfigSchemaNode? itemNode)
+ {
+ Properties = properties;
+ RequiredProperties = requiredProperties;
+ ItemNode = itemNode;
+ }
+
+ public IReadOnlyDictionary? Properties { get; }
+
+ public IReadOnlyCollection? RequiredProperties { get; }
+
+ public YamlConfigSchemaNode? ItemNode { get; }
+ }
+
+ private sealed class NodeValidation
+ {
+ public static NodeValidation None { get; } = new(
+ referenceTableName: null,
+ allowedValues: null,
+ constraints: null,
+ arrayConstraints: null);
+
+ public NodeValidation(
+ string? referenceTableName,
+ IReadOnlyCollection? allowedValues,
+ YamlConfigScalarConstraints? constraints,
+ YamlConfigArrayConstraints? arrayConstraints)
+ {
+ ReferenceTableName = referenceTableName;
+ AllowedValues = allowedValues;
+ Constraints = constraints;
+ ArrayConstraints = arrayConstraints;
+ }
+
+ public string? ReferenceTableName { get; }
+
+ public IReadOnlyCollection? AllowedValues { get; }
+
+ public YamlConfigScalarConstraints? Constraints { get; }
+
+ public YamlConfigArrayConstraints? ArrayConstraints { get; }
+
+ public NodeValidation WithReferenceTable(string referenceTableName)
+ {
+ return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints);
+ }
+ }
}
///
-/// 表示一个标量节点上声明的数值范围、步进或字符串长度约束。
-/// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。
+/// 聚合一个标量节点上声明的数值约束与字符串约束。
+/// 该包装层保留“标量字段有约束”的统一入口,同时把不同语义的约束分成更小的专用模型。
///
internal sealed class YamlConfigScalarConstraints
{
///
/// 初始化标量约束模型。
///
+ /// 数值约束分组。
+ /// 字符串约束分组。
+ public YamlConfigScalarConstraints(
+ YamlConfigNumericConstraints? numericConstraints,
+ YamlConfigStringConstraints? stringConstraints)
+ {
+ NumericConstraints = numericConstraints;
+ StringConstraints = stringConstraints;
+ }
+
+ ///
+ /// 获取数值约束分组。
+ ///
+ public YamlConfigNumericConstraints? NumericConstraints { get; }
+
+ ///
+ /// 获取字符串约束分组。
+ ///
+ public YamlConfigStringConstraints? StringConstraints { get; }
+}
+
+///
+/// 表示标量节点上声明的数值范围与步进约束。
+/// 该类型只覆盖整数 / 浮点共享的关键字,避免字符串字段继续暴露不相关的成员。
+///
+internal sealed class YamlConfigNumericConstraints
+{
+ ///
+ /// 初始化数值约束模型。
+ ///
/// 最小值约束。
/// 最大值约束。
/// 开区间最小值约束。
/// 开区间最大值约束。
/// 数值步进约束。
- /// 最小长度约束。
- /// 最大长度约束。
- /// 正则模式约束。
- /// 已编译的正则表达式。
- public YamlConfigScalarConstraints(
+ public YamlConfigNumericConstraints(
double? minimum,
double? maximum,
double? exclusiveMinimum,
double? exclusiveMaximum,
- double? multipleOf,
- int? minLength,
- int? maxLength,
- string? pattern,
- Regex? patternRegex)
+ double? multipleOf)
{
Minimum = minimum;
Maximum = maximum;
ExclusiveMinimum = exclusiveMinimum;
ExclusiveMaximum = exclusiveMaximum;
MultipleOf = multipleOf;
- MinLength = minLength;
- MaxLength = maxLength;
- Pattern = pattern;
- PatternRegex = patternRegex;
}
///
@@ -2064,6 +2364,32 @@ internal sealed class YamlConfigScalarConstraints
/// 获取数值步进约束。
///
public double? MultipleOf { get; }
+}
+
+///
+/// 表示标量节点上声明的字符串长度与模式约束。
+/// 该模型将正则原文与预编译正则绑定保存,保证诊断内容与运行时匹配逻辑保持一致。
+///
+internal sealed class YamlConfigStringConstraints
+{
+ ///
+ /// 初始化字符串约束模型。
+ ///
+ /// 最小长度约束。
+ /// 最大长度约束。
+ /// 正则模式约束原文。
+ /// 已编译的正则表达式。
+ public YamlConfigStringConstraints(
+ int? minLength,
+ int? maxLength,
+ string? pattern,
+ Regex? patternRegex)
+ {
+ MinLength = minLength;
+ MaxLength = maxLength;
+ Pattern = pattern;
+ PatternRegex = patternRegex;
+ }
///
/// 获取最小长度约束。
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index 7b4389d6..327f8c27 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -708,7 +708,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前按运行时与 JS 共用的浮点容差策略判断十进制步进
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
-- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错
+- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
- `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序
@@ -807,7 +807,7 @@ var hotReload = loader.EnableHotReload(
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
-- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据
+- 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index 25a49b19..d9d7d0a8 100644
--- a/tools/gframework-config-tool/src/configValidation.js
+++ b/tools/gframework-config-tool/src/configValidation.js
@@ -6,6 +6,10 @@ const {
} = require("./configPath");
const {ValidationMessageKeys} = require("./localizationKeys");
+const IntegerScalarPattern = /^[+-]?\d+$/u;
+const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u;
+const BooleanScalarPattern = /^(true|false)$/iu;
+
/**
* Parse the repository's minimal config-schema subset into a recursive tree.
* The parser intentionally mirrors the same high-level contract used by the
@@ -262,11 +266,11 @@ function isScalarCompatible(expectedType, scalarValue) {
const value = unquoteScalar(String(scalarValue));
switch (expectedType) {
case "integer":
- return /^-?\d+$/u.test(value);
+ return IntegerScalarPattern.test(value);
case "number":
- return /^-?\d+(?:\.\d+)?$/u.test(value);
+ return NumberScalarPattern.test(value);
case "boolean":
- return /^(true|false)$/iu.test(value);
+ return BooleanScalarPattern.test(value);
case "string":
return true;
default:
@@ -407,7 +411,7 @@ function normalizeSchemaBoolean(value) {
* @param {unknown} value Raw schema value.
* @param {string} displayPath Logical property path used in diagnostics.
* @throws {Error} Thrown when the pattern string cannot be compiled.
- * @returns {string | undefined} Normalized pattern string.
+ * @returns {{source: string, regex: RegExp} | undefined} Normalized pattern metadata.
*/
function normalizeSchemaPattern(value, displayPath) {
if (typeof value !== "string") {
@@ -415,8 +419,10 @@ function normalizeSchemaPattern(value, displayPath) {
}
try {
- void new RegExp(value);
- return value;
+ return {
+ source: value,
+ regex: new RegExp(value, "u")
+ };
} catch (error) {
throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
}
@@ -454,24 +460,18 @@ function formatSchemaDefaultValue(value) {
}
/**
- * Test one scalar value against one schema pattern string.
+ * Test one scalar value against one compiled schema pattern.
*
* @param {string} scalarValue Scalar value from YAML.
- * @param {string | undefined} pattern Schema pattern string.
- * @param {string} displayPath Logical property path used in diagnostics.
- * @throws {Error} Thrown when the pattern string cannot be compiled.
+ * @param {RegExp | undefined} patternRegex Compiled schema pattern.
* @returns {boolean} True when the value matches or no pattern is declared.
*/
-function matchesSchemaPattern(scalarValue, pattern, displayPath) {
- if (typeof pattern !== "string") {
+function matchesSchemaPattern(scalarValue, patternRegex) {
+ if (!(patternRegex instanceof RegExp)) {
return true;
}
- try {
- return new RegExp(pattern).test(scalarValue);
- } catch (error) {
- throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
- }
+ return patternRegex.test(scalarValue);
}
/**
@@ -500,7 +500,7 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) {
* @returns {string} YAML-ready scalar.
*/
function formatYamlScalar(value) {
- if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) {
+ if (NumberScalarPattern.test(value) || BooleanScalarPattern.test(value)) {
return value;
}
@@ -536,6 +536,7 @@ function unquoteScalar(value) {
function parseSchemaNode(rawNode, displayPath) {
const value = rawNode && typeof rawNode === "object" ? rawNode : {};
const type = typeof value.type === "string" ? value.type : "object";
+ const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
const metadata = {
title: typeof value.title === "string" ? value.title : undefined,
description: typeof value.description === "string" ? value.description : undefined,
@@ -547,7 +548,8 @@ function parseSchemaNode(rawNode, displayPath) {
multipleOf: normalizeSchemaPositiveNumber(value.multipleOf),
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
- pattern: normalizeSchemaPattern(value.pattern, displayPath),
+ pattern: patternMetadata ? patternMetadata.source : undefined,
+ patternRegex: patternMetadata ? patternMetadata.regex : undefined,
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
uniqueItems: normalizeSchemaBoolean(value.uniqueItems),
@@ -622,6 +624,9 @@ function parseSchemaNode(rawNode, displayPath) {
pattern: type === "string"
? metadata.pattern
: undefined,
+ patternRegex: type === "string"
+ ? metadata.patternRegex
+ : undefined,
enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable
};
@@ -675,19 +680,27 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
});
}
+ const comparableItems = [];
for (let index = 0; index < yamlNode.items.length; index += 1) {
+ const diagnosticsBeforeValidation = diagnostics.length;
validateNode(
schemaNode.items,
yamlNode.items[index],
joinArrayIndexPath(displayPath, index),
diagnostics,
localizer);
+
+ // Keep uniqueItems focused on values that are otherwise valid so a
+ // shape/type error does not also surface as a misleading duplicate.
+ if (diagnostics.length === diagnosticsBeforeValidation) {
+ comparableItems.push({index, node: yamlNode.items[index]});
+ }
}
if (schemaNode.uniqueItems === true) {
const seenItems = new Map();
- for (let index = 0; index < yamlNode.items.length; index += 1) {
- const comparableValue = buildComparableNodeValue(schemaNode.items, yamlNode.items[index]);
+ for (const {index, node} of comparableItems) {
+ const comparableValue = buildComparableNodeValue(schemaNode.items, node);
if (seenItems.has(comparableValue)) {
diagnostics.push({
severity: "error",
@@ -696,7 +709,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue))
})
});
- break;
+ continue;
}
seenItems.set(comparableValue, index);
@@ -830,7 +843,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
}
if (supportsPatternConstraints &&
- !matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) {
+ !matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) {
diagnostics.push({
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {
@@ -920,7 +933,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
return Object.keys(schemaNode.properties)
.filter((key) => yamlNode.map.has(key))
.sort((left, right) => left.localeCompare(right))
- .map((key) => `${key.length}:${key}=${buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key))}`)
+ .map((key) => {
+ const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key));
+ return `${key.length}:${key}=${valueKey.length}:${valueKey}`;
+ })
.join("|");
}
@@ -929,7 +945,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
return yamlNode.kind;
}
- return `[${yamlNode.items.map((item) => buildComparableNodeValue(schemaNode.items, item)).join(",")}]`;
+ return `[${yamlNode.items.map((item) => {
+ const valueKey = buildComparableNodeValue(schemaNode.items, item);
+ return `${valueKey.length}:${valueKey}`;
+ }).join(",")}]`;
}
if (yamlNode.kind !== "scalar") {
@@ -942,7 +961,7 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
: schemaNode.type === "boolean"
? String(/^true$/iu.test(scalarValue))
: scalarValue;
- return `${schemaNode.type}:${normalizedScalar}`;
+ return `${schemaNode.type}:${normalizedScalar.length}:${normalizedScalar}`;
}
/**
@@ -1704,6 +1723,7 @@ module.exports = {
* minLength?: number,
* maxLength?: number,
* pattern?: string,
+ * patternRegex?: RegExp,
* enumValues?: string[],
* refTable?: string
* }} SchemaNode
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index 19020fdf..ea105c9b 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -349,6 +349,135 @@ phases:
assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u);
});
+test("validateParsedConfig should accept scientific-notation numbers", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "dropRate": {
+ "type": "number"
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+dropRate: 1.5e10
+`);
+
+ assert.deepEqual(validateParsedConfig(schema, yaml), []);
+});
+
+test("validateParsedConfig should apply schema patterns with Unicode semantics", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^\\\\p{L}+$"
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+name: 测试
+`);
+
+ assert.deepEqual(validateParsedConfig(schema, yaml), []);
+});
+
+test("validateParsedConfig should skip uniqueItems checks for invalid array items", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "values": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+values:
+ -
+ id: 1
+ -
+ id: 2
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 2);
+ assert.match(diagnostics[0].message, /values\[0\]/u);
+ assert.match(diagnostics[1].message, /values\[1\]/u);
+ assert.ok(diagnostics.every((diagnostic) => !/uniqueItems|元素唯一/u.test(diagnostic.message)));
+});
+
+test("validateParsedConfig should report every uniqueItems duplicate in one pass", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "tags": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+tags:
+ - alpha
+ - beta
+ - alpha
+ - beta
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 2);
+ assert.match(diagnostics[0].message, /tags\[2\]/u);
+ assert.match(diagnostics[1].message, /tags\[3\]/u);
+});
+
+test("validateParsedConfig should avoid uniqueItems comparable-key collisions for distinct objects", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "entries": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "object",
+ "properties": {
+ "a": { "type": "string" },
+ "b": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+entries:
+ -
+ a: "x|1:b=string:yz"
+ -
+ a: x
+ b: yz
+`);
+
+ assert.deepEqual(validateParsedConfig(schema, yaml), []);
+});
+
test("parseSchemaContent should capture scalar range and length metadata", () => {
const schema = parseSchemaContent(`
{