diff --git a/AGENTS.md b/AGENTS.md
index 4e39ad22..cb19f63c 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)
@@ -100,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
@@ -114,6 +122,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
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index 059d61aa..1757e22d 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -510,6 +510,183 @@ 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));
+ });
+ }
+
+ ///
+ /// 验证大数值配合十进制步进时,会按十进制精确整倍数规则被运行时接受。
+ ///
+ [Test]
+ public async Task LoadAsync_Should_Accept_Large_Decimal_Number_When_MultipleOf_Matches_Exact_Decimal_Step()
+ {
+ 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));
+ });
+ }
+
+ ///
+ /// 验证大数量级但实际不满足 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 类型被运行时接受。
+ ///
+ [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));
+ });
+ }
+
///
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
///
@@ -762,6 +939,122 @@ 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));
+ });
+ }
+
+ ///
+ /// 验证 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 校验后,未知字段不会再被静默忽略。
///
@@ -1453,7 +1746,7 @@ public class YamlConfigLoaderTests
Assert.That(exception!.ParamName, Is.EqualTo("options"));
}
-
+
///
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
///
@@ -1699,6 +1992,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 校验测试的最小怪物配置类型。
///
@@ -1778,6 +2087,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();
+ }
+
///
/// 表示对象数组中的阶段元素。
///
@@ -1794,6 +2119,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 381f321c..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;
@@ -7,12 +8,17 @@ namespace GFramework.Game.Config;
/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。
/// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致,
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
+/// 当前共享子集额外支持 multipleOf 与 uniqueItems,
+/// 让数值步进和数组去重规则在运行时与生成器 / 工具侧保持一致。
///
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 文件。
@@ -108,7 +114,7 @@ internal static class YamlConfigSchemaValidator
string yamlPath,
string yamlText)
{
- ValidateAndCollectReferences(tableName, schema, yamlPath, yamlText);
+ ValidateCore(tableName, schema, yamlPath, yamlText, references: null);
}
///
@@ -127,6 +133,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))
{
@@ -164,9 +193,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;
}
///
@@ -294,16 +321,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);
}
///
@@ -363,15 +381,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);
}
@@ -394,15 +406,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);
}
@@ -422,7 +430,7 @@ internal static class YamlConfigSchemaValidator
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection references)
+ ICollection? references)
{
switch (schemaNode.NodeType)
{
@@ -468,7 +476,7 @@ internal static class YamlConfigSchemaValidator
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection references)
+ ICollection? references)
{
if (node is not YamlMappingNode mappingNode)
{
@@ -564,7 +572,7 @@ internal static class YamlConfigSchemaValidator
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection references)
+ ICollection? references)
{
if (node is not YamlSequenceNode sequenceNode)
{
@@ -603,6 +611,8 @@ internal static class YamlConfigSchemaValidator
schemaNode.ItemNode,
references);
}
+
+ ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
}
///
@@ -620,7 +630,7 @@ internal static class YamlConfigSchemaValidator
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
- ICollection references)
+ ICollection? references)
{
if (node is not YamlScalarNode scalarNode)
{
@@ -695,7 +705,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(
@@ -776,6 +787,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);
@@ -809,30 +821,20 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath));
}
- if (!minimum.HasValue &&
- !maximum.HasValue &&
- !exclusiveMinimum.HasValue &&
- !exclusiveMaximum.HasValue &&
- !minLength.HasValue &&
- !maxLength.HasValue &&
- pattern is null)
- {
- return null;
- }
-
- return new YamlConfigScalarConstraints(
+ var numericConstraints = CreateNumericScalarConstraints(
minimum,
maximum,
exclusiveMinimum,
exclusiveMaximum,
+ 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);
}
///
@@ -851,6 +853,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 +865,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 +920,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 +1100,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 进入“无任何合法值”的状态。
@@ -1166,123 +1237,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)}.");
- }
-
+ 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:
@@ -1297,6 +1269,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(normalizedValue, 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}.");
+ }
+ }
+
///
/// 校验数组值是否满足元素数量约束。
///
@@ -1345,6 +1576,312 @@ 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)
+ {
+ return schemaNode.NodeType switch
+ {
+ YamlConfigSchemaPropertyType.Object => BuildComparableObjectValue(node, schemaNode),
+ YamlConfigSchemaPropertyType.Array => BuildComparableArrayValue(node, schemaNode),
+ YamlConfigSchemaPropertyType.Integer => BuildComparableScalarValue(node, schemaNode),
+ YamlConfigSchemaPropertyType.Number => BuildComparableScalarValue(node, schemaNode),
+ YamlConfigSchemaPropertyType.Boolean => BuildComparableScalarValue(node, schemaNode),
+ YamlConfigSchemaPropertyType.String => BuildComparableScalarValue(node, schemaNode),
+ _ => throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.")
+ };
+ }
+
+ ///
+ /// 构建对象节点的可比较键。
+ /// 对象字段会先按属性名排序,避免 YAML 原始字段顺序影响 uniqueItems 的等价关系。
+ ///
+ /// YAML 节点。
+ /// 对象 schema 节点。
+ /// 对象节点的稳定比较键。
+ private static string BuildComparableObjectValue(YamlNode node, YamlConfigSchemaNode schemaNode)
+ {
+ if (node is not YamlMappingNode mappingNode)
+ {
+ throw new InvalidOperationException("Validated object nodes must be YAML mappings.");
+ }
+
+ var properties = schemaNode.Properties
+ ?? throw new InvalidOperationException("Validated object nodes must expose declared properties.");
+ var objectEntries = new List>(mappingNode.Children.Count);
+ foreach (var entry in mappingNode.Children)
+ {
+ if (entry.Key is not YamlScalarNode keyNode ||
+ keyNode.Value is null ||
+ !properties.TryGetValue(keyNode.Value, out var propertySchema))
+ {
+ throw new InvalidOperationException("Validated object nodes must use declared scalar property names.");
+ }
+
+ objectEntries.Add(
+ new KeyValuePair(
+ keyNode.Value,
+ BuildComparableNodeValue(entry.Value, propertySchema)));
+ }
+
+ objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
+ return string.Join(
+ "|",
+ objectEntries.Select(static entry =>
+ $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}"));
+ }
+
+ ///
+ /// 构建数组节点的可比较键。
+ /// 数组仍保留元素顺序,因为 uniqueItems 只忽略对象字段顺序,不忽略数组顺序。
+ ///
+ /// YAML 节点。
+ /// 数组 schema 节点。
+ /// 数组节点的稳定比较键。
+ private static string BuildComparableArrayValue(YamlNode node, YamlConfigSchemaNode schemaNode)
+ {
+ if (node is not YamlSequenceNode sequenceNode ||
+ schemaNode.ItemNode is null)
+ {
+ throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema.");
+ }
+
+ return "[" +
+ string.Join(
+ ",",
+ sequenceNode.Children.Select(
+ item =>
+ {
+ var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode);
+ return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
+ })) +
+ "]";
+ }
+
+ ///
+ /// 构建标量节点的可比较键。
+ /// 标量会沿用与 enum / 引用校验一致的归一化规则,避免数字格式和引号形式导致伪差异。
+ ///
+ /// YAML 节点。
+ /// 标量 schema 节点。
+ /// 标量节点的稳定比较键。
+ private static string BuildComparableScalarValue(YamlNode node, YamlConfigSchemaNode schemaNode)
+ {
+ if (node is not YamlScalarNode scalarNode ||
+ scalarNode.Value is null)
+ {
+ throw new InvalidOperationException("Validated scalar nodes must be YAML scalars.");
+ }
+
+ var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value);
+ return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}";
+ }
+
+ ///
+ /// 为唯一性诊断提取一个可读的节点摘要。
+ ///
+ /// YAML 节点。
+ /// 对应 schema 节点。
+ /// 诊断摘要。
+ private static string DescribeYamlNodeForDiagnostics(YamlNode node, YamlConfigSchemaNode schemaNode)
+ {
+ return schemaNode.NodeType switch
+ {
+ YamlConfigSchemaPropertyType.Object => "{...}",
+ YamlConfigSchemaPropertyType.Array => "[...]",
+ _ when node is YamlScalarNode scalarNode => scalarNode.Value ?? string.Empty,
+ _ => node.GetType().Name
+ };
+ }
+
+ ///
+ /// 判断数值是否满足 multipleOf。
+ /// 优先按十进制字面量做精确整倍数判断,
+ /// 以同时避免 0.1 / 0.01 这类十进制步进的伪失败和大数量级非整倍数的伪通过;
+ /// 只有当值超出精确十进制路径时才退回双精度容差比较。
+ ///
+ /// 用于数值比较的规范化 YAML 标量文本。
+ /// 当前值。
+ /// 步进约束。
+ /// 是否满足整倍数关系。
+ private static bool IsMultipleOf(string normalizedValue, double value, double divisor)
+ {
+ if (TryIsExactDecimalMultiple(normalizedValue, divisor, out var exactResult))
+ {
+ return exactResult;
+ }
+
+ var quotient = value / divisor;
+ var nearestInteger = Math.Round(quotient);
+ var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient));
+ return Math.Abs(quotient - nearestInteger) <= tolerance;
+ }
+
+ ///
+ /// 尝试按十进制字面量精确判断 multipleOf。
+ /// 该路径直接对齐 YAML / JSON 中常见的有限十进制写法,
+ /// 避免双精度舍入把明显的非整倍数误判为合法。
+ ///
+ /// 规范化后的 YAML 数值文本。
+ /// Schema 声明的步进约束。
+ /// 精确路径下的判断结果。
+ /// 是否成功进入精确十进制判断路径。
+ private static bool TryIsExactDecimalMultiple(string valueText, double divisor, out bool isMultiple)
+ {
+ var divisorText = divisor.ToString("R", CultureInfo.InvariantCulture);
+ if (!TryParseExactDecimal(valueText, out var valueSignificand, out var valueScale) ||
+ !TryParseExactDecimal(divisorText, out var divisorSignificand, out var divisorScale) ||
+ divisorSignificand.IsZero)
+ {
+ isMultiple = false;
+ return false;
+ }
+
+ var commonScale = Math.Max(valueScale, divisorScale);
+ var scaledValue = ScaleDecimalSignificand(valueSignificand, valueScale, commonScale);
+ var scaledDivisor = ScaleDecimalSignificand(divisorSignificand, divisorScale, commonScale);
+ isMultiple = scaledValue % scaledDivisor == BigInteger.Zero;
+ return true;
+ }
+
+ ///
+ /// 将有限十进制或科学计数法文本拆成“整数有效数字 + 十进制位数”形式。
+ /// 这样可以把整倍数判断转成同一尺度下的整数取模,避免浮点误差参与计算。
+ ///
+ /// 待解析的数值文本。
+ /// 去掉小数点后的有效数字。
+ /// 十进制缩放位数;原值等于 / 10^。
+ /// 是否成功解析为有限十进制数。
+ private static bool TryParseExactDecimal(string text, out BigInteger significand, out int scale)
+ {
+ var match = ExactDecimalPattern.Match(text);
+ if (!match.Success)
+ {
+ significand = BigInteger.Zero;
+ scale = 0;
+ return false;
+ }
+
+ var exponentGroup = match.Groups["exponent"].Value;
+ var exponent = 0;
+ if (!string.IsNullOrEmpty(exponentGroup) &&
+ !int.TryParse(exponentGroup, NumberStyles.Integer, CultureInfo.InvariantCulture, out exponent))
+ {
+ significand = BigInteger.Zero;
+ scale = 0;
+ return false;
+ }
+
+ var integerDigits = match.Groups["integer"].Value;
+ var fractionDigits = match.Groups["fraction"].Success
+ ? match.Groups["fraction"].Value
+ : match.Groups["fractionOnly"].Value;
+ var digits = string.Concat(integerDigits, fractionDigits);
+ if (digits.Length == 0)
+ {
+ digits = "0";
+ }
+
+ digits = digits.TrimStart('0');
+ if (digits.Length == 0)
+ {
+ significand = BigInteger.Zero;
+ scale = 0;
+ return true;
+ }
+
+ scale = checked(fractionDigits.Length - exponent);
+ if (scale < 0)
+ {
+ digits = string.Concat(digits, new string('0', -scale));
+ scale = 0;
+ }
+
+ while (scale > 0 && digits[^1] == '0')
+ {
+ digits = digits[..^1];
+ scale--;
+ }
+
+ significand = BigInteger.Parse(digits, CultureInfo.InvariantCulture);
+ if (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);
+ }
+
///
/// 解析跨表引用目标表名称。
///
@@ -1656,37 +2193,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;
}
@@ -1746,52 +2344,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,
- int? minLength,
- int? maxLength,
- string? pattern,
- Regex? patternRegex)
+ double? multipleOf)
{
Minimum = minimum;
Maximum = maximum;
ExclusiveMinimum = exclusiveMinimum;
ExclusiveMaximum = exclusiveMaximum;
- MinLength = minLength;
- MaxLength = maxLength;
- Pattern = pattern;
- PatternRegex = patternRegex;
+ MultipleOf = multipleOf;
}
///
@@ -1814,6 +2483,37 @@ internal sealed class YamlConfigScalarConstraints
///
public double? ExclusiveMaximum { get; }
+ ///
+ /// 获取数值步进约束。
+ ///
+ 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;
+ }
+
///
/// 获取最小长度约束。
///
@@ -1836,7 +2536,7 @@ internal sealed class YamlConfigScalarConstraints
}
///
-/// 表示一个数组节点上声明的元素数量约束。
+/// 表示一个数组节点上声明的元素数量或去重约束。
/// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。
///
internal sealed class YamlConfigArrayConstraints
@@ -1846,10 +2546,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 +2563,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;
}
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index 5561dccd..e156bfd2 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 解析阶段直接报错
+- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 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` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index e80a559e..39dc7786 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:
@@ -370,6 +374,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 +394,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.
@@ -387,7 +411,7 @@ function normalizeSchemaNonNegativeInteger(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") {
@@ -395,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}`);
}
@@ -434,24 +460,124 @@ 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);
+}
+
+/**
+ * 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 exactDecimalResult = tryMatchesExactDecimalMultiple(scalarValue, String(multipleOf));
+ if (exactDecimalResult !== null) {
+ return exactDecimalResult;
+ }
+
+ 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;
+}
+
+/**
+ * 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));
}
/**
@@ -461,7 +587,7 @@ function matchesSchemaPattern(scalarValue, pattern, displayPath) {
* @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;
}
@@ -497,6 +623,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,
@@ -505,11 +632,14 @@ 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),
+ pattern: patternMetadata ? patternMetadata.source : undefined,
+ patternRegex: patternMetadata ? patternMetadata.regex : undefined,
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 +675,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 +699,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,
@@ -577,6 +711,9 @@ function parseSchemaNode(rawNode, displayPath) {
pattern: type === "string"
? metadata.pattern
: undefined,
+ patternRegex: type === "string"
+ ? metadata.patternRegex
+ : undefined,
enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable
};
@@ -630,14 +767,42 @@ 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 (const {index, node} of comparableItems) {
+ const comparableValue = buildComparableNodeValue(schemaNode.items, node);
+ if (seenItems.has(comparableValue)) {
+ diagnostics.push({
+ severity: "error",
+ message: localizeValidationMessage(ValidationMessageKeys.uniqueItemsViolation, localizer, {
+ displayPath: joinArrayIndexPath(displayPath, index),
+ duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue))
+ })
+ });
+ continue;
+ }
+
+ seenItems.set(comparableValue, index);
+ }
+ }
+
return;
}
@@ -729,6 +894,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) {
@@ -754,7 +930,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, {
@@ -824,6 +1000,57 @@ 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) => {
+ const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key));
+ return `${key.length}:${key}=${valueKey.length}:${valueKey}`;
+ })
+ .join("|");
+ }
+
+ if (schemaNode.type === "array") {
+ if (yamlNode.kind !== "array") {
+ return yamlNode.kind;
+ }
+
+ return `[${yamlNode.items.map((item) => {
+ const valueKey = buildComparableNodeValue(schemaNode.items, item);
+ return `${valueKey.length}:${valueKey}`;
+ }).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.length}:${normalizedScalar}`;
+}
+
/**
* Format one validation message in either English or Simplified Chinese.
*
@@ -859,12 +1086,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 +1128,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 +1793,7 @@ module.exports = {
* defaultValue?: string,
* minItems?: number,
* maxItems?: number,
+ * uniqueItems?: boolean,
* refTable?: string,
* items: SchemaNode
* } | {
@@ -1570,9 +1806,11 @@ module.exports = {
* exclusiveMinimum?: number,
* maximum?: number,
* exclusiveMaximum?: number,
+ * multipleOf?: number,
* minLength?: number,
* maxLength?: number,
* pattern?: string,
+ * patternRegex?: RegExp,
* enumValues?: string[],
* refTable?: string
* }} SchemaNode
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..3b793650 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -308,6 +308,217 @@ 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("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(`
+ {
+ "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(`
{
@@ -378,6 +589,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(`