From c693337ebf377f698d019aa843872ed09bb8becd Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 9 Apr 2026 17:06:20 +0800
Subject: [PATCH 1/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?=
=?UTF-8?q?=E4=BA=8EJSON=20schema=E7=9A=84=E9=85=8D=E7=BD=AE=E7=94=9F?=
=?UTF-8?q?=E6=88=90=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现了SchemaConfigGenerator源代码生成器
- 支持根据JSON schema文件自动生成配置类型
- 生成强类型的配置表包装类
- 支持嵌套对象和对象数组的类型生成
- 生成配置表的查询和索引功能
- 添加了跨表引用的元数据支持
- 生成运行时注册和访问辅助代码
- 支持默认值、枚举和约束的文档生成
---
.../Config/YamlConfigLoaderTests.cs | 102 +++++++
.../Config/YamlConfigSchemaValidator.cs | 268 +++++++++++++++++-
.../SchemaConfigGeneratorSnapshotTests.cs | 2 +
.../SchemaConfigGenerator/MonsterConfig.g.txt | 4 +-
.../Config/SchemaConfigGenerator.cs | 18 +-
5 files changed, 386 insertions(+), 8 deletions(-)
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index 059d61aa..488c3427 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -510,6 +510,54 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证数值不满足 multipleOf 时会在运行时被拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Number_Violates_MultipleOf()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 12
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "hp"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": {
+ "type": "integer",
+ "multipleOf": 5
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation));
+ Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("hp"));
+ Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("12"));
+ Assert.That(exception.Message, Does.Contain("multiple of 5"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
///
@@ -762,6 +810,60 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证数组声明 uniqueItems 后,重复元素会在运行时被拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Array_Violates_UniqueItems()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ dropRates:
+ - 5
+ - 10
+ - 5
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "dropRates"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "dropRates": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation));
+ Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates[2]"));
+ Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("5"));
+ Assert.That(exception.Message, Does.Contain("unique array items"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
///
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 381f321c..c1f85a3e 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -7,6 +7,8 @@ namespace GFramework.Game.Config;
/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。
/// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致,
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
+/// 当前共享子集额外支持 multipleOf 与 uniqueItems,
+/// 让数值步进和数组去重规则在运行时与生成器 / 工具侧保持一致。
///
internal static class YamlConfigSchemaValidator
{
@@ -603,6 +605,8 @@ internal static class YamlConfigSchemaValidator
schemaNode.ItemNode,
references);
}
+
+ ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
}
///
@@ -776,6 +780,7 @@ internal static class YamlConfigSchemaValidator
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);
@@ -813,6 +818,7 @@ internal static class YamlConfigSchemaValidator
!maximum.HasValue &&
!exclusiveMinimum.HasValue &&
!exclusiveMaximum.HasValue &&
+ !multipleOf.HasValue &&
!minLength.HasValue &&
!maxLength.HasValue &&
pattern is null)
@@ -825,6 +831,7 @@ internal static class YamlConfigSchemaValidator
maximum,
exclusiveMinimum,
exclusiveMaximum,
+ multipleOf,
minLength,
maxLength,
pattern,
@@ -851,6 +858,7 @@ internal static class YamlConfigSchemaValidator
{
var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems");
var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems");
+ var uniqueItems = TryParseUniqueItemsConstraint(tableName, schemaPath, propertyPath, element);
if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value)
{
@@ -862,9 +870,9 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath));
}
- return !minItems.HasValue && !maxItems.HasValue
+ return !minItems.HasValue && !maxItems.HasValue && !uniqueItems
? null
- : new YamlConfigArrayConstraints(minItems, maxItems);
+ : new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems);
}
///
@@ -917,6 +925,41 @@ internal static class YamlConfigSchemaValidator
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;
+ }
+
///
/// 读取字符串长度约束。
///
@@ -1062,6 +1105,39 @@ internal static class YamlConfigSchemaValidator
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 进入“无任何合法值”的状态。
@@ -1238,6 +1314,20 @@ internal static class YamlConfigSchemaValidator
$"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)}.");
+ }
+
return;
case YamlConfigSchemaPropertyType.String:
@@ -1345,6 +1435,159 @@ internal static class YamlConfigSchemaValidator
}
}
+ ///
+ /// 校验数组是否满足去重约束。
+ ///
+ /// 所属配置表名称。
+ /// 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);
+ }
+ }
+
+ ///
+ /// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。
+ /// 该键仅用于 uniqueItems,因此要忽略对象字段顺序和字符串引号形式。
+ ///
+ /// YAML 节点。
+ /// 对应 schema 节点。
+ /// 可稳定比较的归一化键。
+ private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode)
+ {
+ switch (schemaNode.NodeType)
+ {
+ case YamlConfigSchemaPropertyType.Object:
+ if (node is not YamlMappingNode mappingNode)
+ {
+ throw new InvalidOperationException("Validated object nodes must be YAML mappings.");
+ }
+
+ 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 节点。
+ /// 对应 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。
+ /// 双精度浮点比较会保留一个与商值量级相关的微小容差,
+ /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败。
+ ///
+ /// 当前值。
+ /// 步进约束。
+ /// 是否满足整倍数关系。
+ private static bool IsMultipleOf(double value, double divisor)
+ {
+ 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;
+ }
+
///
/// 解析跨表引用目标表名称。
///
@@ -1758,7 +2001,7 @@ internal sealed class YamlConfigSchemaNode
}
///
-/// 表示一个标量节点上声明的数值范围或字符串长度约束。
+/// 表示一个标量节点上声明的数值范围、步进或字符串长度约束。
/// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。
///
internal sealed class YamlConfigScalarConstraints
@@ -1770,6 +2013,7 @@ internal sealed class YamlConfigScalarConstraints
/// 最大值约束。
/// 开区间最小值约束。
/// 开区间最大值约束。
+ /// 数值步进约束。
/// 最小长度约束。
/// 最大长度约束。
/// 正则模式约束。
@@ -1779,6 +2023,7 @@ internal sealed class YamlConfigScalarConstraints
double? maximum,
double? exclusiveMinimum,
double? exclusiveMaximum,
+ double? multipleOf,
int? minLength,
int? maxLength,
string? pattern,
@@ -1788,6 +2033,7 @@ internal sealed class YamlConfigScalarConstraints
Maximum = maximum;
ExclusiveMinimum = exclusiveMinimum;
ExclusiveMaximum = exclusiveMaximum;
+ MultipleOf = multipleOf;
MinLength = minLength;
MaxLength = maxLength;
Pattern = pattern;
@@ -1814,6 +2060,11 @@ internal sealed class YamlConfigScalarConstraints
///
public double? ExclusiveMaximum { get; }
+ ///
+ /// 获取数值步进约束。
+ ///
+ public double? MultipleOf { get; }
+
///
/// 获取最小长度约束。
///
@@ -1836,7 +2087,7 @@ internal sealed class YamlConfigScalarConstraints
}
///
-/// 表示一个数组节点上声明的元素数量约束。
+/// 表示一个数组节点上声明的元素数量或去重约束。
/// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。
///
internal sealed class YamlConfigArrayConstraints
@@ -1846,10 +2097,12 @@ internal sealed class YamlConfigArrayConstraints
///
/// 最小元素数量约束。
/// 最大元素数量约束。
- public YamlConfigArrayConstraints(int? minItems, int? maxItems)
+ /// 是否要求数组元素唯一。
+ public YamlConfigArrayConstraints(int? minItems, int? maxItems, bool uniqueItems)
{
MinItems = minItems;
MaxItems = maxItems;
+ UniqueItems = uniqueItems;
}
///
@@ -1861,6 +2114,11 @@ internal sealed class YamlConfigArrayConstraints
/// 获取最大元素数量约束。
///
public int? MaxItems { get; }
+
+ ///
+ /// 获取是否要求数组元素唯一。
+ ///
+ public bool UniqueItems { get; }
}
///
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
index 290985ee..dc2a36b5 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
@@ -92,6 +92,7 @@ public class SchemaConfigGeneratorSnapshotTests
"maximum": 999,
"exclusiveMinimum": 0,
"exclusiveMaximum": 1000,
+ "multipleOf": 5,
"default": 10
},
"dropItems": {
@@ -99,6 +100,7 @@ public class SchemaConfigGeneratorSnapshotTests
"type": "array",
"minItems": 1,
"maxItems": 3,
+ "uniqueItems": true,
"items": {
"type": "string",
"minLength": 3,
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
index 2306fa00..d5b5f909 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt
@@ -34,7 +34,7 @@ public sealed partial class MonsterConfig
///
///
/// Schema property path: 'hp'.
- /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000.
+ /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000, multipleOf = 5.
/// Generated default initializer: = 10;
///
public int? Hp { get; set; } = 10;
@@ -45,7 +45,7 @@ public sealed partial class MonsterConfig
///
/// Schema property path: 'dropItems'.
/// Allowed values: potion, slime_gel.
- /// Constraints: minItems = 1, maxItems = 3.
+ /// Constraints: minItems = 1, maxItems = 3, uniqueItems = true.
/// References config table: 'item'.
/// Item constraints: minLength = 3, maxLength = 12.
/// Generated default initializer: = new string[] { "potion" };
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index ca249109..5890cc1f 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -6,6 +6,8 @@ namespace GFramework.SourceGenerators.Config;
/// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。
/// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集,
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。
+/// 当前共享子集也会把 multipleOf 与 uniqueItems 写入生成代码文档,
+/// 让消费者能直接在强类型 API 上看到运行时生效的约束。
///
[Generator]
public sealed class SchemaConfigGenerator : IIncrementalGenerator
@@ -2430,7 +2432,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
///
- /// 将 shared schema 子集中的范围、长度、模式与数组数量约束整理成 XML 文档可读字符串。
+ /// 将 shared schema 子集中的范围、步进、长度、模式与数组数量 / 去重约束整理成 XML 文档可读字符串。
///
/// Schema 节点。
/// 标量类型。
@@ -2463,6 +2465,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}");
}
+ if ((schemaType == "integer" || schemaType == "number") &&
+ TryGetFiniteNumber(element, "multipleOf", out var multipleOf) &&
+ multipleOf > 0d)
+ {
+ parts.Add($"multipleOf = {multipleOf.ToString(CultureInfo.InvariantCulture)}");
+ }
+
if (schemaType == "string" &&
TryGetNonNegativeInt32(element, "minLength", out var minLength))
{
@@ -2494,6 +2503,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}");
}
+ if (schemaType == "array" &&
+ element.TryGetProperty("uniqueItems", out var uniqueItemsElement) &&
+ uniqueItemsElement.ValueKind == JsonValueKind.True)
+ {
+ parts.Add("uniqueItems = true");
+ }
+
return parts.Count > 0 ? string.Join(", ", parts) : null;
}
From 51de7f11026093dfddcc70a658c956a3a355e3bb Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 9 Apr 2026 17:06:43 +0800
Subject: [PATCH 2/8] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=92=8CYAML=E8=A7=A3=E6=9E=90?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现了配置模式解析器,支持递归对象/数组/标量树结构
- 添加了可编辑字段收集功能,支持批量编辑标量和数组类型
- 实现了YAML解析器,支持嵌套对象、标量数组和对象数组
- 添加了YAML注释提取功能,将注释映射到逻辑字段路径
- 实现了基于模式的示例YAML配置生成功能
- 添加了扩展端验证诊断功能,支持中英文错误消息
- 实现了表单更新应用功能,支持标量、数组和对象数组更新
- 添加了批处理数组值解析和模式枚举值标准化功能
- 实现了YAML标量格式化和引号移除功能
- 添加了完整的模式节点验证,支持数值约束、长度限制和模式匹配
- 实现了多语言验证消息本地化功能
- 添加了YAML标记化和块解析功能
- 实现了唯一性检查和比较键构建功能
---
.../src/configValidation.js | 131 ++++++++++++++++++
tools/gframework-config-tool/src/extension.js | 14 +-
.../src/localization.js | 10 ++
.../src/localizationKeys.js | 2 +
.../test/configValidation.test.js | 67 +++++++++
5 files changed, 223 insertions(+), 1 deletion(-)
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index e80a559e..25a49b19 100644
--- a/tools/gframework-config-tool/src/configValidation.js
+++ b/tools/gframework-config-tool/src/configValidation.js
@@ -370,6 +370,16 @@ function normalizeSchemaNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
+/**
+ * Normalize one strictly positive finite schema number.
+ *
+ * @param {unknown} value Raw schema value.
+ * @returns {number | undefined} Normalized positive number.
+ */
+function normalizeSchemaPositiveNumber(value) {
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
+}
+
/**
* Normalize one non-negative integer schema value for length constraints.
*
@@ -380,6 +390,16 @@ function normalizeSchemaNonNegativeInteger(value) {
return Number.isInteger(value) && value >= 0 ? value : undefined;
}
+/**
+ * Normalize one boolean schema flag.
+ *
+ * @param {unknown} value Raw schema value.
+ * @returns {boolean | undefined} Normalized boolean.
+ */
+function normalizeSchemaBoolean(value) {
+ return typeof value === "boolean" ? value : undefined;
+}
+
/**
* Normalize one schema pattern string when the regular expression can be
* compiled by the local tooling runtime.
@@ -454,6 +474,25 @@ function matchesSchemaPattern(scalarValue, pattern, displayPath) {
}
}
+/**
+ * Test whether one numeric scalar satisfies a multipleOf constraint.
+ *
+ * @param {string} scalarValue YAML scalar value.
+ * @param {number | undefined} multipleOf Schema multipleOf value.
+ * @returns {boolean} True when compatible or the constraint is absent.
+ */
+function matchesSchemaMultipleOf(scalarValue, multipleOf) {
+ if (typeof multipleOf !== "number") {
+ return true;
+ }
+
+ const numericValue = Number(scalarValue);
+ const quotient = numericValue / multipleOf;
+ const nearestInteger = Math.round(quotient);
+ const tolerance = 1e-9 * Math.max(1, Math.abs(quotient));
+ return Math.abs(quotient - nearestInteger) <= tolerance;
+}
+
/**
* Format a scalar value for YAML output.
*
@@ -505,11 +544,13 @@ function parseSchemaNode(rawNode, displayPath) {
exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum),
maximum: normalizeSchemaNumber(value.maximum),
exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum),
+ multipleOf: normalizeSchemaPositiveNumber(value.multipleOf),
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
pattern: normalizeSchemaPattern(value.pattern, displayPath),
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
+ uniqueItems: normalizeSchemaBoolean(value.uniqueItems),
refTable: typeof value["x-gframework-ref-table"] === "string"
? value["x-gframework-ref-table"]
: undefined
@@ -545,6 +586,7 @@ function parseSchemaNode(rawNode, displayPath) {
defaultValue: metadata.defaultValue,
minItems: metadata.minItems,
maxItems: metadata.maxItems,
+ uniqueItems: metadata.uniqueItems === true,
refTable: metadata.refTable,
items: itemNode
};
@@ -568,6 +610,9 @@ function parseSchemaNode(rawNode, displayPath) {
exclusiveMaximum: type === "integer" || type === "number"
? metadata.exclusiveMaximum
: undefined,
+ multipleOf: type === "integer" || type === "number"
+ ? metadata.multipleOf
+ : undefined,
minLength: type === "string"
? metadata.minLength
: undefined,
@@ -638,6 +683,26 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
diagnostics,
localizer);
}
+
+ 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]);
+ if (seenItems.has(comparableValue)) {
+ diagnostics.push({
+ severity: "error",
+ message: localizeValidationMessage(ValidationMessageKeys.uniqueItemsViolation, localizer, {
+ displayPath: joinArrayIndexPath(displayPath, index),
+ duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue))
+ })
+ });
+ break;
+ }
+
+ seenItems.set(comparableValue, index);
+ }
+ }
+
return;
}
@@ -729,6 +794,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
});
}
+ if (supportsNumericConstraints &&
+ !matchesSchemaMultipleOf(scalarValue, schemaNode.multipleOf)) {
+ diagnostics.push({
+ severity: "error",
+ message: localizeValidationMessage(ValidationMessageKeys.multipleOfViolation, localizer, {
+ displayPath,
+ value: String(schemaNode.multipleOf)
+ })
+ });
+ }
+
if (supportsLengthConstraints &&
typeof schemaNode.minLength === "number" &&
scalarValue.length < schemaNode.minLength) {
@@ -824,6 +900,51 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
}
}
+/**
+ * Build one schema-aware comparable key for uniqueItems checks.
+ *
+ * @param {SchemaNode} schemaNode Schema node.
+ * @param {YamlNode | undefined} yamlNode YAML node.
+ * @returns {string} Comparable key.
+ */
+function buildComparableNodeValue(schemaNode, yamlNode) {
+ if (!yamlNode) {
+ return "missing";
+ }
+
+ if (schemaNode.type === "object") {
+ if (yamlNode.kind !== "object") {
+ return yamlNode.kind;
+ }
+
+ 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))}`)
+ .join("|");
+ }
+
+ if (schemaNode.type === "array") {
+ if (yamlNode.kind !== "array") {
+ return yamlNode.kind;
+ }
+
+ return `[${yamlNode.items.map((item) => buildComparableNodeValue(schemaNode.items, item)).join(",")}]`;
+ }
+
+ if (yamlNode.kind !== "scalar") {
+ return yamlNode.kind;
+ }
+
+ const scalarValue = unquoteScalar(yamlNode.value);
+ const normalizedScalar = schemaNode.type === "integer" || schemaNode.type === "number"
+ ? String(Number(scalarValue))
+ : schemaNode.type === "boolean"
+ ? String(/^true$/iu.test(scalarValue))
+ : scalarValue;
+ return `${schemaNode.type}:${normalizedScalar}`;
+}
+
/**
* Format one validation message in either English or Simplified Chinese.
*
@@ -859,12 +980,16 @@ function localizeValidationMessage(key, localizer, params) {
return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`;
case ValidationMessageKeys.minimumViolation:
return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`;
+ case ValidationMessageKeys.multipleOfViolation:
+ return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`;
case ValidationMessageKeys.minItemsViolation:
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`;
case ValidationMessageKeys.minLengthViolation:
return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`;
case ValidationMessageKeys.patternViolation:
return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`;
+ case ValidationMessageKeys.uniqueItemsViolation:
+ return `属性“${params.displayPath}”与更早的数组元素 ${params.duplicatePath} 重复;该数组要求元素唯一。`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired:
@@ -897,12 +1022,16 @@ function localizeValidationMessage(key, localizer, params) {
return `Property '${params.displayPath}' must be at most ${params.value} characters long.`;
case ValidationMessageKeys.minimumViolation:
return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`;
+ case ValidationMessageKeys.multipleOfViolation:
+ return `Property '${params.displayPath}' must be a multiple of ${params.value}.`;
case ValidationMessageKeys.minItemsViolation:
return `Property '${params.displayPath}' must contain at least ${params.value} items.`;
case ValidationMessageKeys.minLengthViolation:
return `Property '${params.displayPath}' must be at least ${params.value} characters long.`;
case ValidationMessageKeys.patternViolation:
return `Property '${params.displayPath}' must match pattern '${params.value}'.`;
+ case ValidationMessageKeys.uniqueItemsViolation:
+ return `Property '${params.displayPath}' duplicates earlier array item '${params.duplicatePath}', but uniqueItems is required.`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired:
@@ -1558,6 +1687,7 @@ module.exports = {
* defaultValue?: string,
* minItems?: number,
* maxItems?: number,
+ * uniqueItems?: boolean,
* refTable?: string,
* items: SchemaNode
* } | {
@@ -1570,6 +1700,7 @@ module.exports = {
* exclusiveMinimum?: number,
* maximum?: number,
* exclusiveMaximum?: number,
+ * multipleOf?: number,
* minLength?: number,
* maxLength?: number,
* pattern?: string,
diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js
index 870f01e7..c78c8aa4 100644
--- a/tools/gframework-config-tool/src/extension.js
+++ b/tools/gframework-config-tool/src/extension.js
@@ -1574,7 +1574,7 @@ function getScalarArrayValue(yamlNode) {
/**
* Render human-facing metadata hints for one schema field.
*
- * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
+ * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array.
* @returns {string} HTML fragment.
*/
@@ -1614,6 +1614,10 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMaximum", {value: propertySchema.exclusiveMaximum})));
}
+ if (!isArrayField && typeof propertySchema.multipleOf === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.multipleOf", {value: propertySchema.multipleOf})));
+ }
+
if (!isArrayField && typeof propertySchema.minLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength})));
}
@@ -1634,6 +1638,10 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems})));
}
+ if (isArrayField && propertySchema.uniqueItems === true) {
+ hints.push(escapeHtml(localizer.t("webview.hint.uniqueItems")));
+ }
+
if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum})));
}
@@ -1650,6 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMaximum", {value: propertySchema.items.exclusiveMaximum})));
}
+ if (isArrayField && propertySchema.items && typeof propertySchema.items.multipleOf === "number") {
+ hints.push(escapeHtml(localizer.t("webview.hint.itemMultipleOf", {value: propertySchema.items.multipleOf})));
+ }
+
if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength})));
}
diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js
index b73c3848..f58bddf2 100644
--- a/tools/gframework-config-tool/src/localization.js
+++ b/tools/gframework-config-tool/src/localization.js
@@ -109,15 +109,18 @@ const enMessages = {
"webview.hint.exclusiveMinimum": "Exclusive minimum: {value}",
"webview.hint.maximum": "Maximum: {value}",
"webview.hint.exclusiveMaximum": "Exclusive maximum: {value}",
+ "webview.hint.multipleOf": "Multiple of: {value}",
"webview.hint.minLength": "Min length: {value}",
"webview.hint.maxLength": "Max length: {value}",
"webview.hint.pattern": "Pattern: {value}",
"webview.hint.minItems": "Min items: {value}",
"webview.hint.maxItems": "Max items: {value}",
+ "webview.hint.uniqueItems": "Items must be unique",
"webview.hint.itemMinimum": "Item minimum: {value}",
"webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}",
"webview.hint.itemMaximum": "Item maximum: {value}",
"webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}",
+ "webview.hint.itemMultipleOf": "Item multiple of: {value}",
"webview.hint.itemMinLength": "Item min length: {value}",
"webview.hint.itemMaxLength": "Item max length: {value}",
"webview.hint.itemPattern": "Item pattern: {value}",
@@ -132,9 +135,11 @@ const enMessages = {
[ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.",
[ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.",
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
+ [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.",
[ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
[ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.",
+ [ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.",
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
@@ -208,15 +213,18 @@ const zhCnMessages = {
"webview.hint.exclusiveMinimum": "开区间最小值:{value}",
"webview.hint.maximum": "最大值:{value}",
"webview.hint.exclusiveMaximum": "开区间最大值:{value}",
+ "webview.hint.multipleOf": "倍数约束:{value}",
"webview.hint.minLength": "最小长度:{value}",
"webview.hint.maxLength": "最大长度:{value}",
"webview.hint.pattern": "正则模式:{value}",
"webview.hint.minItems": "最少元素数:{value}",
"webview.hint.maxItems": "最多元素数:{value}",
+ "webview.hint.uniqueItems": "元素必须唯一",
"webview.hint.itemMinimum": "元素最小值:{value}",
"webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}",
"webview.hint.itemMaximum": "元素最大值:{value}",
"webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}",
+ "webview.hint.itemMultipleOf": "元素倍数约束:{value}",
"webview.hint.itemMinLength": "元素最小长度:{value}",
"webview.hint.itemMaxLength": "元素最大长度:{value}",
"webview.hint.itemPattern": "元素正则模式:{value}",
@@ -231,9 +239,11 @@ const zhCnMessages = {
[ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。",
[ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。",
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
+ [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。",
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",
[ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。",
+ [ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。",
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
[ValidationMessageKeys.expectedObject]: "{subject}",
diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js
index 2eb991ff..3e315ff9 100644
--- a/tools/gframework-config-tool/src/localizationKeys.js
+++ b/tools/gframework-config-tool/src/localizationKeys.js
@@ -10,10 +10,12 @@ const ValidationMessageKeys = Object.freeze({
maxItemsViolation: "validation.maxItemsViolation",
maxLengthViolation: "validation.maxLengthViolation",
minimumViolation: "validation.minimumViolation",
+ multipleOfViolation: "validation.multipleOfViolation",
minItemsViolation: "validation.minItemsViolation",
minLengthViolation: "validation.minLengthViolation",
missingRequired: "validation.missingRequired",
patternViolation: "validation.patternViolation",
+ uniqueItemsViolation: "validation.uniqueItemsViolation",
unknownProperty: "validation.unknownProperty"
});
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index 0c3ebaab..19020fdf 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -308,6 +308,47 @@ tags:
assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
});
+test("validateParsedConfig should report multipleOf and uniqueItems violations", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "hp": {
+ "type": "integer",
+ "multipleOf": 5
+ },
+ "phases": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "object",
+ "properties": {
+ "wave": { "type": "integer" },
+ "monsterId": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+hp: 12
+phases:
+ -
+ wave: 1
+ monsterId: slime
+ -
+ monsterId: slime
+ wave: 1
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 2);
+ assert.match(diagnostics[0].message, /multiple of 5|5 的整数倍/u);
+ assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u);
+});
+
test("parseSchemaContent should capture scalar range and length metadata", () => {
const schema = parseSchemaContent(`
{
@@ -378,6 +419,32 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite
assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$");
});
+test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "hp": {
+ "type": "integer",
+ "multipleOf": 5
+ },
+ "dropRates": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "number",
+ "multipleOf": 0.5
+ }
+ }
+ }
+ }
+ `);
+
+ assert.equal(schema.properties.hp.multipleOf, 5);
+ assert.equal(schema.properties.dropRates.uniqueItems, true);
+ assert.equal(schema.properties.dropRates.items.multipleOf, 0.5);
+});
+
test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
assert.throws(
() => parseSchemaContent(`
From f5317eda01d2c1106017b620e675c8b25ac8bf87 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 9 Apr 2026 17:07:29 +0800
Subject: [PATCH 3/8] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?=
=?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 介绍面向静态游戏内容的 AI-First 配表方案
- 详细说明 YAML 配置源文件和 JSON Schema 结构描述功能
- 提供推荐目录结构和 Schema 示例配置指南
- 说明 VS Code 插件提供的配置浏览和编辑功能
- 提供运行时接入模板和强类型查询辅助使用方法
- 说明跨表引用和运行时校验行为规范
- 介绍开发期热重载功能和性能优化建议
- 说明当前限制和独立 Config Studio 评估结论
---
docs/zh-CN/game/config-system.md | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index 5561dccd..7b4389d6 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -12,7 +12,7 @@
- JSON Schema 作为结构描述
- 一对象一文件的目录组织
- 运行时只读查询
-- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`
+- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
@@ -53,7 +53,8 @@ GameProject/
},
"hp": {
"type": "integer",
- "default": 10
+ "default": 10,
+ "multipleOf": 5
},
"rarity": {
"type": "string",
@@ -62,6 +63,7 @@ GameProject/
"dropItems": {
"type": "array",
"description": "掉落物品表主键。",
+ "uniqueItems": true,
"items": {
"type": "string",
"enum": ["potion", "slime_gel", "bomb"]
@@ -650,9 +652,11 @@ var loader = new YamlConfigLoader("config-root")
- 对象数组元素结构不匹配
- 数值字段违反 `minimum` / `maximum`
- 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum`
+- 数值字段违反 `multipleOf`
- 字符串字段违反 `minLength` / `maxLength`
- 字符串字段违反 `pattern`
- 数组字段违反 `minItems` / `maxItems`
+- 数组字段违反 `uniqueItems`
- 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@@ -702,9 +706,11 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
+- `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前按运行时与 JS 共用的浮点容差策略判断十进制步进
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
+- `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
@@ -801,7 +807,7 @@ var hotReload = loader.EnableHotReload(
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
-- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
+- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
From 16686a0d9746f39dbd4187b2e50890957fe7c8b6 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 9 Apr 2026 19:23:06 +0800
Subject: [PATCH 4/8] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?=
=?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E9=AA=8C=E8=AF=81=E5=B7=A5=E5=85=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增游戏内容配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等
- 添加 Schema 示例和 YAML 示例,说明怪物、物品等静态数据配置方式
- 提供推荐接入模板,包括目录结构、csproj 配置和启动代码模板
- 实现官方启动帮助器 GameConfigBootstrap 与 GameConfigModule 集成
- 添加运行时读取模板,提供强类型配置访问入口
- 实现生成查询辅助功能,支持 FindBy* 和 TryFindFirstBy* 查询接口
- 提供 Architecture 推荐接入模板,支持模块化配置管理
- 添加热重载模板,支持开发期配置文件自动刷新
- 实现运行时接入方案,提供只读表形式的配置访问
- 添加运行时校验行为说明,支持跨表引用和数据完整性检查
- 实现开发期热重载功能,支持配置变更自动重载
- 添加生成器接入约定,自动生成配置类型和表包装代码
- 提供 VS Code 工具支持,包括配置浏览、表单编辑和批量更新功能
- 实现配置验证工具,支持 JSON Schema 子集解析和 YAML 校验功能
---
.../Config/YamlConfigLoaderTests.cs | 151 +++
.../Config/YamlConfigSchemaValidator.cs | 886 ++++++++++++------
docs/zh-CN/game/config-system.md | 4 +-
.../src/configValidation.js | 72 +-
.../test/configValidation.test.js | 129 +++
5 files changed, 934 insertions(+), 308 deletions(-)
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(`
{
From f9f608ad64f77265be8bd62a5b81ef97d7b1e0aa Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 9 Apr 2026 19:23:17 +0800
Subject: [PATCH 5/8] =?UTF-8?q?docs(agents):=20=E6=B7=BB=E5=8A=A0AI?=
=?UTF-8?q?=E4=BB=A3=E7=90=86=E7=BC=96=E7=A0=81=E8=A1=8C=E4=B8=BA=E8=A7=84?=
=?UTF-8?q?=E8=8C=83=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 定义了环境能力清单和工具选择规则
- 规定了XML文档注释、内联注释和架构级注释要求
- 明确了代码风格包括命名约定、格式化和C#惯例
- 设立了测试覆盖范围、组织结构和验证命令标准
- 制定了安全规则防止输入验证和敏感数据泄露
- 规范了代码文档、任务跟踪和仓库文档更新流程
- 建立了审查标准确保代码质量和完整性
---
AGENTS.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 4e39ad22..311042dd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,10 @@ All AI agents and contributors must follow these rules when writing, reviewing,
- Use `@.ai/environment/tools.raw.yaml` only when you need the full collected facts behind the AI-facing hints.
- Prefer the project-relevant tools listed there instead of assuming every installed system tool is fair game.
- If the real environment differs from the inventory, use the project-relevant installed tool and report the mismatch.
+- When working in WSL against this repository's Windows-backed worktree, prefer Windows Git from WSL (for example
+ `git.exe`) instead of the Linux `git` binary.
+- If a Git command in WSL fails with a worktree-style “not a git repository” path translation error, rerun it with the
+ Windows Git executable and treat that as the repository-default Git path for the rest of the task.
## Commenting Rules (MUST)
@@ -114,6 +118,15 @@ All generated or modified code MUST include clear and meaningful comments where
### Analyzer and Validation Expectations
- The repository uses `Meziantou.Analyzer`; treat analyzer feedback as part of the coding standard.
+- Treat SonarQube maintainability rules as part of the coding standard as well, especially cognitive complexity and
+ oversized parameter list findings.
+- When a method approaches analyzer complexity limits, prefer extracting named helper methods by semantic phase
+ (parsing, normalization, validation, diagnostics) instead of silencing the warning or doing cosmetic reshuffles.
+- When a constructor or method exceeds parameter count limits, choose the refactor that matches the shape of the API:
+ use domain-specific value objects or parameter objects for naturally grouped data, and prefer named factory methods
+ when the call site is really selecting between different creation modes.
+- Do not add suppressions for complexity or parameter-count findings unless the constraint is externally imposed and the
+ reason is documented in code comments.
- Naming must remain compatible with `scripts/validate-csharp-naming.sh`.
## Testing Requirements
From ccb6b5ad4283f4218a76103924389cfa41b42e78 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 9 Apr 2026 19:43:15 +0800
Subject: [PATCH 6/8] =?UTF-8?q?docs(agents):=20=E6=B7=BB=E5=8A=A0AI?=
=?UTF-8?q?=E4=BB=A3=E7=90=86=E8=A1=8C=E4=B8=BA=E5=87=86=E5=88=99=E6=96=87?=
=?UTF-8?q?=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 定义了环境能力清单和工具选择规则
- 规定了XML文档注释、内联注释和架构级注释要求
- 明确了代码风格包括命名规范、格式化和C#约定
- 设立了测试覆盖范围、组织结构和验证命令标准
- 制定了安全输入验证、依赖管理和权限控制规则
- 确立了文档更新、任务跟踪和审查完成标准
- 更新了浮点数比较算法中的容差计算方式
- 优化了数值精度验证逻辑以避免十进制步进误差
---
AGENTS.md | 4 ++++
GFramework.Game/Config/YamlConfigSchemaValidator.cs | 9 +++++----
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 311042dd..cb19f63c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -104,6 +104,10 @@ All generated or modified code MUST include clear and meaningful comments where
- Keep `using` directives at the top of the file and sort them consistently.
- Separate logical blocks with blank lines when it improves readability.
- Prefer one primary type per file unless the surrounding project already uses a different local pattern.
+- Unless there is a clear and documented reason to keep a file large, keep a single source file under roughly 800-1000
+ lines.
+- If a file grows beyond that range, contributors MUST stop and check whether responsibilities should be split before
+ continuing; treating oversized files as the default is considered a design smell.
- Keep line length readable. Around 120 characters is the preferred upper bound.
### C# Conventions
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index d8c09f9f..f7dc74c4 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -1745,8 +1745,9 @@ internal static class YamlConfigSchemaValidator
///
/// 判断数值是否满足 multipleOf。
- /// 双精度浮点比较会保留一个与商值量级相关的微小容差,
- /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败。
+ /// 双精度浮点比较会保留一个与步进量级相关的微小容差,
+ /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败,
+ /// 同时避免值越大就无限放宽合法余数范围。
///
/// 当前值。
/// 步进约束。
@@ -1755,8 +1756,8 @@ internal static class YamlConfigSchemaValidator
{
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;
+ var tolerance = 1e-9 * Math.Max(1d, Math.Abs(divisor));
+ return Math.Abs(value - (nearestInteger * divisor)) <= tolerance;
}
///
From 3ec34298578003945bb7468276d125cf1e99ccfa Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 9 Apr 2026 19:58:48 +0800
Subject: [PATCH 7/8] =?UTF-8?q?test(config):=20=E6=B7=BB=E5=8A=A0YAML?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=8D=95=E5=85=83?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 验证YAML文件扫描和注册表写入功能
- 测试带schema校验的配置表注册选项对象支持
- 验证空配置表注册选项的异常处理
- 测试配置目录不存在时的错误抛出
- 验证配置表加载失败时注册表状态回滚
- 测试非法YAML文件的反序列化错误处理
- 验证schema校验对必填字段缺失的检查
- 测试schema校验对类型不匹配的检查
- 验证schema校验对枚举值限制的支持
- 测试数值范围约束的校验功能
- 验证数值exclusive min/max约束
- 测试multipleOf约束校验
- 验证大数值和科学计数法支持
- 测试字符串长度和正则模式约束
- 验证数组元素数量和唯一性约束
- 测试未知字段检测和错误处理
- 验证嵌套对象和数组的递归校验
- 测试跨表引用校验功能
---
.../Config/YamlConfigLoaderTests.cs | 44 +++++++++++++++++++
.../Config/YamlConfigSchemaValidator.cs | 10 ++---
2 files changed, 49 insertions(+), 5 deletions(-)
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index 29d57119..a8f45394 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -558,6 +558,50 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证大数值配合十进制步进时,会沿用 JS 工具侧的 multipleOf 容差策略。
+ ///
+ [Test]
+ public async Task LoadAsync_Should_Accept_Large_Decimal_Number_When_MultipleOf_Matches_Js_Tolerance()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ dropRate: 10000000.2
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "dropRate"],
+ "properties": {
+ "id": { "type": "integer" },
+ "dropRate": {
+ "type": "number",
+ "multipleOf": 0.1
+ }
+ }
+ }
+ """);
+
+ 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(10000000.2d));
+ });
+ }
+
///
/// 验证科学计数法数值会按 number 类型被运行时接受。
///
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index f7dc74c4..efeda8fa 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -1745,9 +1745,9 @@ internal static class YamlConfigSchemaValidator
///
/// 判断数值是否满足 multipleOf。
- /// 双精度浮点比较会保留一个与步进量级相关的微小容差,
- /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败,
- /// 同时避免值越大就无限放宽合法余数范围。
+ /// 双精度浮点比较会在商空间保留一个与商量级相关的微小容差,
+ /// 以对齐 JS 工具侧对 0.1 / 0.01 这类十进制步进的判定方式,
+ /// 避免出现“编辑器通过、运行时拒绝”的跨环境漂移。
///
/// 当前值。
/// 步进约束。
@@ -1756,8 +1756,8 @@ internal static class YamlConfigSchemaValidator
{
var quotient = value / divisor;
var nearestInteger = Math.Round(quotient);
- var tolerance = 1e-9 * Math.Max(1d, Math.Abs(divisor));
- return Math.Abs(value - (nearestInteger * divisor)) <= tolerance;
+ var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient));
+ return Math.Abs(quotient - nearestInteger) <= tolerance;
}
///
From d263a4360ecd25ae2e447a72f959a7dbda18aacc Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Thu, 9 Apr 2026 20:26:13 +0800
Subject: [PATCH 8/8] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?=
=?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E9=AA=8C=E8=AF=81=E5=B7=A5=E5=85=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增游戏内容配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等
- 实现运行时只读查询、Source Generator 类型生成、VS Code 插件等功能
- 提供配置浏览、raw 编辑、schema 打开、递归校验和嵌套对象表单入口
- 添加配置系统接入模板,包括 csproj 模板、启动帮助器、运行时读取模板
- 实现热重载功能支持开发期配置文件自动刷新
- 提供完整的 schema 示例和 YAML 示例配置
- 添加跨表引用、索引查询辅助、批量编辑等高级功能支持
- 实现配置验证工具,支持类型校验、约束检查、注释提取等特性
---
.../Config/YamlConfigLoaderTests.cs | 50 ++++++-
.../Config/YamlConfigSchemaValidator.cs | 132 +++++++++++++++++-
docs/zh-CN/game/config-system.md | 2 +-
.../src/configValidation.js | 87 ++++++++++++
.../test/configValidation.test.js | 41 ++++++
5 files changed, 303 insertions(+), 9 deletions(-)
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index a8f45394..1757e22d 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -559,10 +559,10 @@ public class YamlConfigLoaderTests
}
///
- /// 验证大数值配合十进制步进时,会沿用 JS 工具侧的 multipleOf 容差策略。
+ /// 验证大数值配合十进制步进时,会按十进制精确整倍数规则被运行时接受。
///
[Test]
- public async Task LoadAsync_Should_Accept_Large_Decimal_Number_When_MultipleOf_Matches_Js_Tolerance()
+ public async Task LoadAsync_Should_Accept_Large_Decimal_Number_When_MultipleOf_Matches_Exact_Decimal_Step()
{
CreateConfigFile(
"monster/slime.yaml",
@@ -602,6 +602,50 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证大数量级但实际不满足 multipleOf 的数值会被运行时拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Large_Number_Is_Not_Actually_MultipleOf()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ dropRate: 1000000000000.4
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "dropRate"],
+ "properties": {
+ "id": { "type": "integer" },
+ "dropRate": {
+ "type": "number",
+ "multipleOf": 1
+ }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+
+ var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation));
+ Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRate"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证科学计数法数值会按 number 类型被运行时接受。
///
@@ -1702,7 +1746,7 @@ public class YamlConfigLoaderTests
Assert.That(exception!.ParamName, Is.EqualTo("options"));
}
-
+
///
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
///
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index efeda8fa..7af85af0 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -1,3 +1,4 @@
+using System.Numerics;
using System.Text.RegularExpressions;
using GFramework.Game.Abstractions.Config;
@@ -15,6 +16,9 @@ internal static 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 static readonly Regex ExactDecimalPattern = new(
+ @"^(?[+-]?)(?:(?\d+)(?:\.(?\d*))?|\.(?\d+))(?:[eE](?[+-]?\d+))?$",
+ RegexOptions.CultureInvariant | RegexOptions.Compiled);
///
/// 从磁盘加载并解析一个 JSON Schema 文件。
@@ -1409,7 +1413,7 @@ internal static class YamlConfigSchemaValidator
}
if (constraints.MultipleOf.HasValue &&
- !IsMultipleOf(numericValue, constraints.MultipleOf.Value))
+ !IsMultipleOf(normalizedValue, numericValue, constraints.MultipleOf.Value))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
@@ -1745,21 +1749,139 @@ internal static class YamlConfigSchemaValidator
///
/// 判断数值是否满足 multipleOf。
- /// 双精度浮点比较会在商空间保留一个与商量级相关的微小容差,
- /// 以对齐 JS 工具侧对 0.1 / 0.01 这类十进制步进的判定方式,
- /// 避免出现“编辑器通过、运行时拒绝”的跨环境漂移。
+ /// 优先按十进制字面量做精确整倍数判断,
+ /// 以同时避免 0.1 / 0.01 这类十进制步进的伪失败和大数量级非整倍数的伪通过;
+ /// 只有当值超出精确十进制路径时才退回双精度容差比较。
///
+ /// 用于数值比较的规范化 YAML 标量文本。
/// 当前值。
/// 步进约束。
/// 是否满足整倍数关系。
- private static bool IsMultipleOf(double value, double divisor)
+ 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 (match.Groups["sign"].Value == "-")
+ {
+ 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);
+ }
+
///
/// 解析跨表引用目标表名称。
///
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index 327f8c27..e156bfd2 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -706,7 +706,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
-- `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前按运行时与 JS 共用的浮点容差策略判断十进制步进
+- `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前优先按运行时与 JS 共用的十进制精确整倍数判定处理常见十进制步进,并在必要时退回浮点容差兜底
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index d9d7d0a8..39dc7786 100644
--- a/tools/gframework-config-tool/src/configValidation.js
+++ b/tools/gframework-config-tool/src/configValidation.js
@@ -486,6 +486,11 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) {
return true;
}
+ const exactDecimalResult = tryMatchesExactDecimalMultiple(scalarValue, String(multipleOf));
+ if (exactDecimalResult !== null) {
+ return exactDecimalResult;
+ }
+
const numericValue = Number(scalarValue);
const quotient = numericValue / multipleOf;
const nearestInteger = Math.round(quotient);
@@ -493,6 +498,88 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) {
return Math.abs(quotient - nearestInteger) <= tolerance;
}
+/**
+ * Try to evaluate one multipleOf constraint using exact decimal arithmetic.
+ * This keeps common YAML / JSON decimal literals aligned with the runtime and
+ * avoids large-number false positives that a pure floating-point quotient check can miss.
+ *
+ * @param {string} valueText YAML scalar text.
+ * @param {string} divisorText Schema multipleOf text.
+ * @returns {boolean | null} Exact result, or null when the inputs cannot be normalized exactly.
+ */
+function tryMatchesExactDecimalMultiple(valueText, divisorText) {
+ const valueParts = tryParseExactDecimal(valueText);
+ const divisorParts = tryParseExactDecimal(divisorText);
+ if (!valueParts || !divisorParts || divisorParts.significand === 0n) {
+ return null;
+ }
+
+ const commonScale = Math.max(valueParts.scale, divisorParts.scale);
+ const scaledValue = scaleDecimalSignificand(valueParts.significand, valueParts.scale, commonScale);
+ const scaledDivisor = scaleDecimalSignificand(divisorParts.significand, divisorParts.scale, commonScale);
+ return scaledValue % scaledDivisor === 0n;
+}
+
+/**
+ * Normalize a finite decimal literal into an integer significand plus decimal scale.
+ * The normalized form lets multipleOf checks run as integer modulo instead of floating-point math.
+ *
+ * @param {string} text Numeric text to normalize.
+ * @returns {{significand: bigint, scale: number} | null} Normalized parts, or null for unsupported input.
+ */
+function tryParseExactDecimal(text) {
+ const match = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/u.exec(String(text).trim());
+ if (!match) {
+ return null;
+ }
+
+ const exponent = match[5] ? Number.parseInt(match[5], 10) : 0;
+ if (!Number.isSafeInteger(exponent)) {
+ return null;
+ }
+
+ const integerDigits = match[2] ?? "";
+ const fractionDigits = match[3] !== undefined ? match[3] : (match[4] ?? "");
+ let digits = `${integerDigits}${fractionDigits}`.replace(/^0+/u, "");
+ if (digits.length === 0) {
+ return {significand: 0n, scale: 0};
+ }
+
+ let scale = fractionDigits.length - exponent;
+ if (scale < 0) {
+ digits += "0".repeat(-scale);
+ scale = 0;
+ }
+
+ while (scale > 0 && digits.endsWith("0")) {
+ digits = digits.slice(0, -1);
+ scale -= 1;
+ }
+
+ let significand = BigInt(digits);
+ if (match[1] === "-") {
+ significand = -significand;
+ }
+
+ return {significand, scale};
+}
+
+/**
+ * Scale one normalized decimal significand to a larger decimal precision.
+ *
+ * @param {bigint} significand Integer significand.
+ * @param {number} currentScale Current decimal scale.
+ * @param {number} targetScale Target decimal scale.
+ * @returns {bigint} Scaled significand.
+ */
+function scaleDecimalSignificand(significand, currentScale, targetScale) {
+ if (currentScale === targetScale) {
+ return significand;
+ }
+
+ return significand * (10n ** BigInt(targetScale - currentScale));
+}
+
/**
* Format a scalar value for YAML output.
*
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index ea105c9b..3b793650 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -349,6 +349,47 @@ phases:
assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u);
});
+test("validateParsedConfig should accept large decimal multiples without floating-point drift", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "dropRate": {
+ "type": "number",
+ "multipleOf": 0.1
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+dropRate: 10000000.2
+`);
+
+ assert.deepEqual(validateParsedConfig(schema, yaml), []);
+});
+
+test("validateParsedConfig should reject large numbers that are not actually multiples", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "dropRate": {
+ "type": "number",
+ "multipleOf": 1
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+dropRate: 1000000000000.4
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 1);
+ assert.match(diagnostics[0].message, /multiple of 1|1 的整数倍/u);
+});
+
test("validateParsedConfig should accept scientific-notation numbers", () => {
const schema = parseSchemaContent(`
{