docs(config): 添加游戏内容配置系统文档和验证工具

- 新增游戏内容配置系统完整文档,涵盖 YAML 配置、JSON Schema 结构、目录组织等
- 添加 Schema 示例和 YAML 示例,说明怪物、物品等静态数据配置方式
- 提供推荐接入模板,包括目录结构、csproj 配置和启动代码模板
- 实现官方启动帮助器 GameConfigBootstrap 与 GameConfigModule 集成
- 添加运行时读取模板,提供强类型配置访问入口
- 实现生成查询辅助功能,支持 FindBy* 和 TryFindFirstBy* 查询接口
- 提供 Architecture 推荐接入模板,支持模块化配置管理
- 添加热重载模板,支持开发期配置文件自动刷新
- 实现运行时接入方案,提供只读表形式的配置访问
- 添加运行时校验行为说明,支持跨表引用和数据完整性检查
- 实现开发期热重载功能,支持配置变更自动重载
- 添加生成器接入约定,自动生成配置类型和表包装代码
- 提供 VS Code 工具支持,包括配置浏览、表单编辑和批量更新功能
- 实现配置验证工具,支持 JSON Schema 子集解析和 YAML 校验功能
This commit is contained in:
GeWuYou 2026-04-09 19:23:06 +08:00
parent f5317eda01
commit 16686a0d97
5 changed files with 934 additions and 308 deletions

View File

@ -558,6 +558,47 @@ public class YamlConfigLoaderTests
}); });
} }
/// <summary>
/// 验证科学计数法数值会按 <c>number</c> 类型被运行时接受。
/// </summary>
[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<int, MonsterNumberConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterNumberConfigStub>("monster");
Assert.Multiple(() =>
{
Assert.That(table.Count, Is.EqualTo(1));
Assert.That(table.Get(1).DropRate, Is.EqualTo(1.5e10));
});
}
/// <summary> /// <summary>
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
/// </summary> /// </summary>
@ -864,6 +905,68 @@ public class YamlConfigLoaderTests
}); });
} }
/// <summary>
/// 验证 <c>uniqueItems</c> 的归一化键不会把带分隔符的不同对象值误判为重复项。
/// </summary>
[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<int, MonsterComparableEntryArrayConfigStub>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterComparableEntryArrayConfigStub>("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"));
});
}
/// <summary> /// <summary>
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// 验证启用 schema 校验后,未知字段不会再被静默忽略。
/// </summary> /// </summary>
@ -1801,6 +1904,22 @@ public class YamlConfigLoaderTests
public int Hp { get; set; } public int Hp { get; set; }
} }
/// <summary>
/// 用于浮点数 schema 校验测试的最小怪物配置类型。
/// </summary>
private sealed class MonsterNumberConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置浮点掉落率。
/// </summary>
public double DropRate { get; set; }
}
/// <summary> /// <summary>
/// 用于数组 schema 校验测试的最小怪物配置类型。 /// 用于数组 schema 校验测试的最小怪物配置类型。
/// </summary> /// </summary>
@ -1880,6 +1999,22 @@ public class YamlConfigLoaderTests
public IReadOnlyList<PhaseConfigStub> Phases { get; set; } = Array.Empty<PhaseConfigStub>(); public IReadOnlyList<PhaseConfigStub> Phases { get; set; } = Array.Empty<PhaseConfigStub>();
} }
/// <summary>
/// 用于 <c>uniqueItems</c> 比较键碰撞回归测试的最小配置类型。
/// </summary>
private sealed class MonsterComparableEntryArrayConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置待比较对象数组。
/// </summary>
public List<ComparableEntryConfigStub> Entries { get; set; } = new();
}
/// <summary> /// <summary>
/// 表示对象数组中的阶段元素。 /// 表示对象数组中的阶段元素。
/// </summary> /// </summary>
@ -1896,6 +2031,22 @@ public class YamlConfigLoaderTests
public string MonsterId { get; set; } = string.Empty; public string MonsterId { get; set; } = string.Empty;
} }
/// <summary>
/// 表示用于比较键碰撞回归测试的对象数组元素。
/// </summary>
private sealed class ComparableEntryConfigStub
{
/// <summary>
/// 获取或设置字段 A。
/// </summary>
public string A { get; set; } = string.Empty;
/// <summary>
/// 获取或设置字段 B。
/// </summary>
public string B { get; set; } = string.Empty;
}
/// <summary> /// <summary>
/// 用于深层跨表引用测试的怪物配置类型。 /// 用于深层跨表引用测试的怪物配置类型。
/// </summary> /// </summary>

View File

@ -110,7 +110,7 @@ internal static class YamlConfigSchemaValidator
string yamlPath, string yamlPath,
string yamlText) string yamlText)
{ {
ValidateAndCollectReferences(tableName, schema, yamlPath, yamlText); ValidateCore(tableName, schema, yamlPath, yamlText, references: null);
} }
/// <summary> /// <summary>
@ -129,6 +129,29 @@ internal static class YamlConfigSchemaValidator
YamlConfigSchema schema, YamlConfigSchema schema,
string yamlPath, string yamlPath,
string yamlText) string yamlText)
{
var references = new List<YamlConfigReferenceUsage>();
ValidateCore(tableName, schema, yamlPath, yamlText, references);
return references;
}
/// <summary>
/// 执行共享的 YAML 结构校验流程,并按需收集跨表引用。
/// 这样 <see cref="Validate" /> 可以复用同一条校验链路,同时避免为“不关心引用结果”的调用方分配临时列表。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param>
/// <param name="references">可选的跨表引用收集器;为 <see langword="null" /> 时只做结构校验。</param>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 YAML 内容与 schema 不匹配时抛出。</exception>
private static void ValidateCore(
string tableName,
YamlConfigSchema schema,
string yamlPath,
string yamlText,
ICollection<YamlConfigReferenceUsage>? references)
{ {
if (string.IsNullOrWhiteSpace(tableName)) if (string.IsNullOrWhiteSpace(tableName))
{ {
@ -166,9 +189,7 @@ internal static class YamlConfigSchemaValidator
schemaPath: schema.SchemaPath); schemaPath: schema.SchemaPath);
} }
var references = new List<YamlConfigReferenceUsage>();
ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references); ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references);
return references;
} }
/// <summary> /// <summary>
@ -296,16 +317,7 @@ internal static class YamlConfigSchemaValidator
property.Value); property.Value);
} }
return new YamlConfigSchemaNode( return YamlConfigSchemaNode.CreateObject(properties, requiredProperties, schemaPath);
YamlConfigSchemaPropertyType.Object,
properties,
requiredProperties,
itemNode: null,
referenceTableName: null,
allowedValues: null,
constraints: null,
arrayConstraints: null,
schemaPath);
} }
/// <summary> /// <summary>
@ -365,15 +377,9 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath)); displayPath: GetDiagnosticPath(propertyPath));
} }
return new YamlConfigSchemaNode( return YamlConfigSchemaNode.CreateArray(
YamlConfigSchemaPropertyType.Array,
properties: null,
requiredProperties: null,
itemNode, itemNode,
referenceTableName: null, ParseArrayConstraints(tableName, schemaPath, propertyPath, element),
allowedValues: null,
constraints: null,
arrayConstraints: ParseArrayConstraints(tableName, schemaPath, propertyPath, element),
schemaPath); schemaPath);
} }
@ -396,15 +402,11 @@ internal static class YamlConfigSchemaValidator
string? referenceTableName) string? referenceTableName)
{ {
EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName); EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName);
return new YamlConfigSchemaNode( return YamlConfigSchemaNode.CreateScalar(
nodeType, nodeType,
properties: null,
requiredProperties: null,
itemNode: null,
referenceTableName, referenceTableName,
ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"),
ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType), ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType),
arrayConstraints: null,
schemaPath); schemaPath);
} }
@ -424,7 +426,7 @@ internal static class YamlConfigSchemaValidator
string displayPath, string displayPath,
YamlNode node, YamlNode node,
YamlConfigSchemaNode schemaNode, YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage> references) ICollection<YamlConfigReferenceUsage>? references)
{ {
switch (schemaNode.NodeType) switch (schemaNode.NodeType)
{ {
@ -470,7 +472,7 @@ internal static class YamlConfigSchemaValidator
string displayPath, string displayPath,
YamlNode node, YamlNode node,
YamlConfigSchemaNode schemaNode, YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage> references) ICollection<YamlConfigReferenceUsage>? references)
{ {
if (node is not YamlMappingNode mappingNode) if (node is not YamlMappingNode mappingNode)
{ {
@ -566,7 +568,7 @@ internal static class YamlConfigSchemaValidator
string displayPath, string displayPath,
YamlNode node, YamlNode node,
YamlConfigSchemaNode schemaNode, YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage> references) ICollection<YamlConfigReferenceUsage>? references)
{ {
if (node is not YamlSequenceNode sequenceNode) if (node is not YamlSequenceNode sequenceNode)
{ {
@ -624,7 +626,7 @@ internal static class YamlConfigSchemaValidator
string displayPath, string displayPath,
YamlNode node, YamlNode node,
YamlConfigSchemaNode schemaNode, YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage> references) ICollection<YamlConfigReferenceUsage>? references)
{ {
if (node is not YamlScalarNode scalarNode) if (node is not YamlScalarNode scalarNode)
{ {
@ -699,7 +701,8 @@ internal static class YamlConfigSchemaValidator
ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode); ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode);
} }
if (schemaNode.ReferenceTableName != null) if (schemaNode.ReferenceTableName != null &&
references is not null)
{ {
references.Add( references.Add(
new YamlConfigReferenceUsage( new YamlConfigReferenceUsage(
@ -814,32 +817,20 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath)); displayPath: GetDiagnosticPath(propertyPath));
} }
if (!minimum.HasValue && var numericConstraints = CreateNumericScalarConstraints(
!maximum.HasValue &&
!exclusiveMinimum.HasValue &&
!exclusiveMaximum.HasValue &&
!multipleOf.HasValue &&
!minLength.HasValue &&
!maxLength.HasValue &&
pattern is null)
{
return null;
}
return new YamlConfigScalarConstraints(
minimum, minimum,
maximum, maximum,
exclusiveMinimum, exclusiveMinimum,
exclusiveMaximum, exclusiveMaximum,
multipleOf, multipleOf);
var stringConstraints = CreateStringScalarConstraints(
minLength, minLength,
maxLength, maxLength,
pattern, pattern);
pattern is null
return numericConstraints is null && stringConstraints is null
? null ? null
: new Regex( : new YamlConfigScalarConstraints(numericConstraints, stringConstraints);
pattern,
SupportedPatternRegexOptions));
} }
/// <summary> /// <summary>
@ -1242,22 +1233,129 @@ internal static class YamlConfigSchemaValidator
{ {
case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Integer:
case YamlConfigSchemaPropertyType.Number: case YamlConfigSchemaPropertyType.Number:
if (!double.TryParse( ValidateNumericScalarConstraints(
tableName,
yamlPath,
displayPath,
rawValue,
normalizedValue, normalizedValue,
NumberStyles.Float | NumberStyles.AllowThousands, schemaNode,
CultureInfo.InvariantCulture, constraints.NumericConstraints);
out var numericValue)) return;
{
case YamlConfigSchemaPropertyType.String:
ValidateStringScalarConstraints(
tableName,
yamlPath,
displayPath,
rawValue,
schemaNode,
constraints.StringConstraints);
return;
default:
throw ConfigLoadExceptionFactory.Create( throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnexpectedFailure, ConfigLoadFailureKind.UnexpectedFailure,
tableName, tableName,
$"Property '{displayPath}' in config file '{yamlPath}' could not be normalized into a comparable numeric value.", $"Property '{displayPath}' in config file '{yamlPath}' resolved unsupported constraint host type '{schemaNode.NodeType}'.",
yamlPath: yamlPath, yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint, schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath), displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue); rawValue: schemaNode.NodeType.ToString());
}
} }
/// <summary>
/// 根据已读取的数值关键字创建数值约束对象。
/// 该分组让调用方不必再维护一个超过 Sonar 默认阈值的长参数构造函数。
/// </summary>
/// <param name="minimum">最小值约束。</param>
/// <param name="maximum">最大值约束。</param>
/// <param name="exclusiveMinimum">开区间最小值约束。</param>
/// <param name="exclusiveMaximum">开区间最大值约束。</param>
/// <param name="multipleOf">数值步进约束。</param>
/// <returns>数值约束对象;未声明任何数值约束时返回空。</returns>
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);
}
/// <summary>
/// 根据已读取的字符串关键字创建字符串约束对象。
/// 正则会在 schema 解析阶段预编译,避免每次校验都重复实例化。
/// </summary>
/// <param name="minLength">最小长度约束。</param>
/// <param name="maxLength">最大长度约束。</param>
/// <param name="pattern">正则模式约束。</param>
/// <returns>字符串约束对象;未声明任何字符串约束时返回空。</returns>
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));
}
/// <summary>
/// 校验数值标量的区间与步进约束。
/// 该方法把解析失败、闭区间、开区间和步进诊断集中到数值路径,避免主调度方法继续增长。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="rawValue">原始 YAML 标量值。</param>
/// <param name="normalizedValue">归一化后的比较值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <param name="constraints">数值约束对象。</param>
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) if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value)
{ {
throw ConfigLoadExceptionFactory.Create( throw ConfigLoadExceptionFactory.Create(
@ -1268,8 +1366,7 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint, schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath), displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue, rawValue: rawValue,
detail: detail: $"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}.");
$"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}.");
} }
if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value) if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value)
@ -1282,8 +1379,7 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint, schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath), displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue, rawValue: rawValue,
detail: detail: $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}.");
$"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}.");
} }
if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value) if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value)
@ -1296,8 +1392,7 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint, schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath), displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue, rawValue: rawValue,
detail: detail: $"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}.");
$"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}.");
} }
if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value) if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value)
@ -1310,8 +1405,7 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint, schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath), displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue, rawValue: rawValue,
detail: detail: $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}.");
$"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}.");
} }
if (constraints.MultipleOf.HasValue && if (constraints.MultipleOf.HasValue &&
@ -1327,12 +1421,68 @@ internal static class YamlConfigSchemaValidator
rawValue: rawValue, rawValue: rawValue,
detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}."); detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}.");
} }
}
/// <summary>
/// 将归一化后的数值文本还原为双精度值,用于统一后续区间比较。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="rawValue">原始 YAML 标量值。</param>
/// <param name="normalizedValue">归一化后的比较值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <returns>可比较的双精度值。</returns>
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);
}
/// <summary>
/// 校验字符串标量的长度与模式约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="rawValue">原始 YAML 标量值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <param name="constraints">字符串约束对象。</param>
private static void ValidateStringScalarConstraints(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
YamlConfigSchemaNode schemaNode,
YamlConfigStringConstraints? constraints)
{
if (constraints is null)
{
return; return;
}
case YamlConfigSchemaPropertyType.String:
var stringLength = rawValue.Length; var stringLength = rawValue.Length;
if (constraints.MinLength.HasValue && stringLength < constraints.MinLength.Value) if (constraints.MinLength.HasValue && stringLength < constraints.MinLength.Value)
{ {
throw ConfigLoadExceptionFactory.Create( throw ConfigLoadExceptionFactory.Create(
@ -1372,19 +1522,6 @@ internal static class YamlConfigSchemaValidator
rawValue: rawValue, rawValue: rawValue,
detail: $"Expected pattern: {constraints.Pattern}."); detail: $"Expected pattern: {constraints.Pattern}.");
} }
return;
default:
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnexpectedFailure,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' resolved unsupported constraint host type '{schemaNode.NodeType}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: schemaNode.NodeType.ToString());
}
} }
/// <summary> /// <summary>
@ -1492,21 +1629,40 @@ internal static class YamlConfigSchemaValidator
/// <returns>可稳定比较的归一化键。</returns> /// <returns>可稳定比较的归一化键。</returns>
private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode) private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{ {
switch (schemaNode.NodeType) 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}'.")
};
}
/// <summary>
/// 构建对象节点的可比较键。
/// 对象字段会先按属性名排序,避免 YAML 原始字段顺序影响 <c>uniqueItems</c> 的等价关系。
/// </summary>
/// <param name="node">YAML 节点。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <returns>对象节点的稳定比较键。</returns>
private static string BuildComparableObjectValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{ {
case YamlConfigSchemaPropertyType.Object:
if (node is not YamlMappingNode mappingNode) if (node is not YamlMappingNode mappingNode)
{ {
throw new InvalidOperationException("Validated object nodes must be YAML mappings."); 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<KeyValuePair<string, string>>(mappingNode.Children.Count); var objectEntries = new List<KeyValuePair<string, string>>(mappingNode.Children.Count);
foreach (var entry in mappingNode.Children) foreach (var entry in mappingNode.Children)
{ {
if (entry.Key is not YamlScalarNode keyNode || if (entry.Key is not YamlScalarNode keyNode ||
keyNode.Value is null || keyNode.Value is null ||
schemaNode.Properties is null || !properties.TryGetValue(keyNode.Value, out var propertySchema))
!schemaNode.Properties.TryGetValue(keyNode.Value, out var propertySchema))
{ {
throw new InvalidOperationException("Validated object nodes must use declared scalar property names."); throw new InvalidOperationException("Validated object nodes must use declared scalar property names.");
} }
@ -1522,8 +1678,17 @@ internal static class YamlConfigSchemaValidator
"|", "|",
objectEntries.Select(static entry => objectEntries.Select(static entry =>
$"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}"));
}
case YamlConfigSchemaPropertyType.Array: /// <summary>
/// 构建数组节点的可比较键。
/// 数组仍保留元素顺序,因为 <c>uniqueItems</c> 只忽略对象字段顺序,不忽略数组顺序。
/// </summary>
/// <param name="node">YAML 节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
/// <returns>数组节点的稳定比较键。</returns>
private static string BuildComparableArrayValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{
if (node is not YamlSequenceNode sequenceNode || if (node is not YamlSequenceNode sequenceNode ||
schemaNode.ItemNode is null) schemaNode.ItemNode is null)
{ {
@ -1534,13 +1699,23 @@ internal static class YamlConfigSchemaValidator
string.Join( string.Join(
",", ",",
sequenceNode.Children.Select( sequenceNode.Children.Select(
item => BuildComparableNodeValue(item, schemaNode.ItemNode))) + item =>
{
var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode);
return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
})) +
"]"; "]";
}
case YamlConfigSchemaPropertyType.Integer: /// <summary>
case YamlConfigSchemaPropertyType.Number: /// 构建标量节点的可比较键。
case YamlConfigSchemaPropertyType.Boolean: /// 标量会沿用与 enum / 引用校验一致的归一化规则,避免数字格式和引号形式导致伪差异。
case YamlConfigSchemaPropertyType.String: /// </summary>
/// <param name="node">YAML 节点。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <returns>标量节点的稳定比较键。</returns>
private static string BuildComparableScalarValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{
if (node is not YamlScalarNode scalarNode || if (node is not YamlScalarNode scalarNode ||
scalarNode.Value is null) scalarNode.Value is null)
{ {
@ -1549,10 +1724,6 @@ internal static class YamlConfigSchemaValidator
var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value); var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value);
return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}"; return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}";
default:
throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.");
}
} }
/// <summary> /// <summary>
@ -1899,37 +2070,98 @@ internal sealed class YamlConfigSchema
/// </summary> /// </summary>
internal sealed class YamlConfigSchemaNode internal sealed class YamlConfigSchemaNode
{ {
private readonly NodeChildren _children;
private readonly NodeValidation _validation;
/// <summary> /// <summary>
/// 初始化一个 schema 节点描述。 /// 创建对象节点描述。
/// </summary> /// </summary>
/// <param name="nodeType">节点类型。</param>
/// <param name="properties">对象属性集合。</param> /// <param name="properties">对象属性集合。</param>
/// <param name="requiredProperties">对象必填属性集合。</param> /// <param name="requiredProperties">对象必填属性集合。</param>
/// <param name="itemNode">数组元素节点。</param>
/// <param name="referenceTableName">目标引用表名称。</param>
/// <param name="allowedValues">标量允许值集合。</param>
/// <param name="constraints">标量范围与长度约束。</param>
/// <param name="arrayConstraints">数组元素数量约束。</param>
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param> /// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
public YamlConfigSchemaNode( /// <returns>对象节点模型。</returns>
YamlConfigSchemaPropertyType nodeType, public static YamlConfigSchemaNode CreateObject(
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties, IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
IReadOnlyCollection<string>? requiredProperties, IReadOnlyCollection<string>? requiredProperties,
YamlConfigSchemaNode? itemNode, string schemaPathHint)
string? referenceTableName, {
IReadOnlyCollection<string>? allowedValues, return new YamlConfigSchemaNode(
YamlConfigScalarConstraints? constraints, YamlConfigSchemaPropertyType.Object,
new NodeChildren(properties, requiredProperties, itemNode: null),
NodeValidation.None,
schemaPathHint);
}
/// <summary>
/// 创建数组节点描述。
/// </summary>
/// <param name="itemNode">数组元素节点。</param>
/// <param name="arrayConstraints">数组元素数量约束。</param>
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
/// <returns>数组节点模型。</returns>
public static YamlConfigSchemaNode CreateArray(
YamlConfigSchemaNode itemNode,
YamlConfigArrayConstraints? arrayConstraints, YamlConfigArrayConstraints? arrayConstraints,
string schemaPathHint) string schemaPathHint)
{ {
return new YamlConfigSchemaNode(
YamlConfigSchemaPropertyType.Array,
new NodeChildren(properties: null, requiredProperties: null, itemNode),
new NodeValidation(
referenceTableName: null,
allowedValues: null,
constraints: null,
arrayConstraints),
schemaPathHint);
}
/// <summary>
/// 创建标量节点描述。
/// </summary>
/// <param name="nodeType">标量节点类型。</param>
/// <param name="referenceTableName">目标引用表名称。</param>
/// <param name="allowedValues">标量允许值集合。</param>
/// <param name="constraints">标量范围与长度约束。</param>
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
/// <returns>标量节点模型。</returns>
public static YamlConfigSchemaNode CreateScalar(
YamlConfigSchemaPropertyType nodeType,
string? referenceTableName,
IReadOnlyCollection<string>? 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; NodeType = nodeType;
Properties = properties; Properties = children.Properties;
RequiredProperties = requiredProperties; RequiredProperties = children.RequiredProperties;
ItemNode = itemNode; ItemNode = children.ItemNode;
ReferenceTableName = referenceTableName; ReferenceTableName = validation.ReferenceTableName;
AllowedValues = allowedValues; AllowedValues = validation.AllowedValues;
Constraints = constraints; Constraints = validation.Constraints;
ArrayConstraints = arrayConstraints; ArrayConstraints = validation.ArrayConstraints;
SchemaPathHint = schemaPathHint; SchemaPathHint = schemaPathHint;
} }
@ -1989,55 +2221,123 @@ internal sealed class YamlConfigSchemaNode
{ {
return new YamlConfigSchemaNode( return new YamlConfigSchemaNode(
NodeType, NodeType,
Properties, _children,
RequiredProperties, _validation.WithReferenceTable(referenceTableName),
ItemNode,
referenceTableName,
AllowedValues,
Constraints,
ArrayConstraints,
SchemaPathHint); SchemaPathHint);
} }
private sealed class NodeChildren
{
public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null);
public NodeChildren(
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
IReadOnlyCollection<string>? requiredProperties,
YamlConfigSchemaNode? itemNode)
{
Properties = properties;
RequiredProperties = requiredProperties;
ItemNode = itemNode;
}
public IReadOnlyDictionary<string, YamlConfigSchemaNode>? Properties { get; }
public IReadOnlyCollection<string>? 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<string>? allowedValues,
YamlConfigScalarConstraints? constraints,
YamlConfigArrayConstraints? arrayConstraints)
{
ReferenceTableName = referenceTableName;
AllowedValues = allowedValues;
Constraints = constraints;
ArrayConstraints = arrayConstraints;
}
public string? ReferenceTableName { get; }
public IReadOnlyCollection<string>? AllowedValues { get; }
public YamlConfigScalarConstraints? Constraints { get; }
public YamlConfigArrayConstraints? ArrayConstraints { get; }
public NodeValidation WithReferenceTable(string referenceTableName)
{
return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints);
}
}
} }
/// <summary> /// <summary>
/// 表示一个标量节点上声明的数值范围、步进或字符串长度约束。 /// 聚合一个标量节点上声明的数值约束与字符串约束。
/// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。 /// 该包装层保留“标量字段有约束”的统一入口,同时把不同语义的约束分成更小的专用模型
/// </summary> /// </summary>
internal sealed class YamlConfigScalarConstraints internal sealed class YamlConfigScalarConstraints
{ {
/// <summary> /// <summary>
/// 初始化标量约束模型。 /// 初始化标量约束模型。
/// </summary> /// </summary>
/// <param name="numericConstraints">数值约束分组。</param>
/// <param name="stringConstraints">字符串约束分组。</param>
public YamlConfigScalarConstraints(
YamlConfigNumericConstraints? numericConstraints,
YamlConfigStringConstraints? stringConstraints)
{
NumericConstraints = numericConstraints;
StringConstraints = stringConstraints;
}
/// <summary>
/// 获取数值约束分组。
/// </summary>
public YamlConfigNumericConstraints? NumericConstraints { get; }
/// <summary>
/// 获取字符串约束分组。
/// </summary>
public YamlConfigStringConstraints? StringConstraints { get; }
}
/// <summary>
/// 表示标量节点上声明的数值范围与步进约束。
/// 该类型只覆盖整数 / 浮点共享的关键字,避免字符串字段继续暴露不相关的成员。
/// </summary>
internal sealed class YamlConfigNumericConstraints
{
/// <summary>
/// 初始化数值约束模型。
/// </summary>
/// <param name="minimum">最小值约束。</param> /// <param name="minimum">最小值约束。</param>
/// <param name="maximum">最大值约束。</param> /// <param name="maximum">最大值约束。</param>
/// <param name="exclusiveMinimum">开区间最小值约束。</param> /// <param name="exclusiveMinimum">开区间最小值约束。</param>
/// <param name="exclusiveMaximum">开区间最大值约束。</param> /// <param name="exclusiveMaximum">开区间最大值约束。</param>
/// <param name="multipleOf">数值步进约束。</param> /// <param name="multipleOf">数值步进约束。</param>
/// <param name="minLength">最小长度约束。</param> public YamlConfigNumericConstraints(
/// <param name="maxLength">最大长度约束。</param>
/// <param name="pattern">正则模式约束。</param>
/// <param name="patternRegex">已编译的正则表达式。</param>
public YamlConfigScalarConstraints(
double? minimum, double? minimum,
double? maximum, double? maximum,
double? exclusiveMinimum, double? exclusiveMinimum,
double? exclusiveMaximum, double? exclusiveMaximum,
double? multipleOf, double? multipleOf)
int? minLength,
int? maxLength,
string? pattern,
Regex? patternRegex)
{ {
Minimum = minimum; Minimum = minimum;
Maximum = maximum; Maximum = maximum;
ExclusiveMinimum = exclusiveMinimum; ExclusiveMinimum = exclusiveMinimum;
ExclusiveMaximum = exclusiveMaximum; ExclusiveMaximum = exclusiveMaximum;
MultipleOf = multipleOf; MultipleOf = multipleOf;
MinLength = minLength;
MaxLength = maxLength;
Pattern = pattern;
PatternRegex = patternRegex;
} }
/// <summary> /// <summary>
@ -2064,6 +2364,32 @@ internal sealed class YamlConfigScalarConstraints
/// 获取数值步进约束。 /// 获取数值步进约束。
/// </summary> /// </summary>
public double? MultipleOf { get; } public double? MultipleOf { get; }
}
/// <summary>
/// 表示标量节点上声明的字符串长度与模式约束。
/// 该模型将正则原文与预编译正则绑定保存,保证诊断内容与运行时匹配逻辑保持一致。
/// </summary>
internal sealed class YamlConfigStringConstraints
{
/// <summary>
/// 初始化字符串约束模型。
/// </summary>
/// <param name="minLength">最小长度约束。</param>
/// <param name="maxLength">最大长度约束。</param>
/// <param name="pattern">正则模式约束原文。</param>
/// <param name="patternRegex">已编译的正则表达式。</param>
public YamlConfigStringConstraints(
int? minLength,
int? maxLength,
string? pattern,
Regex? patternRegex)
{
MinLength = minLength;
MaxLength = maxLength;
Pattern = pattern;
PatternRegex = patternRegex;
}
/// <summary> /// <summary>
/// 获取最小长度约束。 /// 获取最小长度约束。

View File

@ -708,7 +708,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `exclusiveMinimum` / `exclusiveMaximum`供运行时校验、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 文档复用 - `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 文档复用 - `minItems` / `maxItems`供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
- `uniqueItems`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 - `uniqueItems`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序
@ -807,7 +807,7 @@ var hotReload = loader.EnableHotReload(
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新 - 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据 - 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。

View File

@ -6,6 +6,10 @@ const {
} = require("./configPath"); } = require("./configPath");
const {ValidationMessageKeys} = require("./localizationKeys"); 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. * Parse the repository's minimal config-schema subset into a recursive tree.
* The parser intentionally mirrors the same high-level contract used by the * 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)); const value = unquoteScalar(String(scalarValue));
switch (expectedType) { switch (expectedType) {
case "integer": case "integer":
return /^-?\d+$/u.test(value); return IntegerScalarPattern.test(value);
case "number": case "number":
return /^-?\d+(?:\.\d+)?$/u.test(value); return NumberScalarPattern.test(value);
case "boolean": case "boolean":
return /^(true|false)$/iu.test(value); return BooleanScalarPattern.test(value);
case "string": case "string":
return true; return true;
default: default:
@ -407,7 +411,7 @@ function normalizeSchemaBoolean(value) {
* @param {unknown} value Raw schema value. * @param {unknown} value Raw schema value.
* @param {string} displayPath Logical property path used in diagnostics. * @param {string} displayPath Logical property path used in diagnostics.
* @throws {Error} Thrown when the pattern string cannot be compiled. * @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) { function normalizeSchemaPattern(value, displayPath) {
if (typeof value !== "string") { if (typeof value !== "string") {
@ -415,8 +419,10 @@ function normalizeSchemaPattern(value, displayPath) {
} }
try { try {
void new RegExp(value); return {
return value; source: value,
regex: new RegExp(value, "u")
};
} catch (error) { } catch (error) {
throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); 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} scalarValue Scalar value from YAML.
* @param {string | undefined} pattern Schema pattern string. * @param {RegExp | undefined} patternRegex Compiled schema pattern.
* @param {string} displayPath Logical property path used in diagnostics.
* @throws {Error} Thrown when the pattern string cannot be compiled.
* @returns {boolean} True when the value matches or no pattern is declared. * @returns {boolean} True when the value matches or no pattern is declared.
*/ */
function matchesSchemaPattern(scalarValue, pattern, displayPath) { function matchesSchemaPattern(scalarValue, patternRegex) {
if (typeof pattern !== "string") { if (!(patternRegex instanceof RegExp)) {
return true; return true;
} }
try { return patternRegex.test(scalarValue);
return new RegExp(pattern).test(scalarValue);
} catch (error) {
throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
}
} }
/** /**
@ -500,7 +500,7 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) {
* @returns {string} YAML-ready scalar. * @returns {string} YAML-ready scalar.
*/ */
function formatYamlScalar(value) { function formatYamlScalar(value) {
if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) { if (NumberScalarPattern.test(value) || BooleanScalarPattern.test(value)) {
return value; return value;
} }
@ -536,6 +536,7 @@ function unquoteScalar(value) {
function parseSchemaNode(rawNode, displayPath) { function parseSchemaNode(rawNode, displayPath) {
const value = rawNode && typeof rawNode === "object" ? rawNode : {}; const value = rawNode && typeof rawNode === "object" ? rawNode : {};
const type = typeof value.type === "string" ? value.type : "object"; const type = typeof value.type === "string" ? value.type : "object";
const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
const metadata = { const metadata = {
title: typeof value.title === "string" ? value.title : undefined, title: typeof value.title === "string" ? value.title : undefined,
description: typeof value.description === "string" ? value.description : undefined, description: typeof value.description === "string" ? value.description : undefined,
@ -547,7 +548,8 @@ function parseSchemaNode(rawNode, displayPath) {
multipleOf: normalizeSchemaPositiveNumber(value.multipleOf), multipleOf: normalizeSchemaPositiveNumber(value.multipleOf),
minLength: normalizeSchemaNonNegativeInteger(value.minLength), minLength: normalizeSchemaNonNegativeInteger(value.minLength),
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
pattern: normalizeSchemaPattern(value.pattern, displayPath), pattern: patternMetadata ? patternMetadata.source : undefined,
patternRegex: patternMetadata ? patternMetadata.regex : undefined,
minItems: normalizeSchemaNonNegativeInteger(value.minItems), minItems: normalizeSchemaNonNegativeInteger(value.minItems),
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
uniqueItems: normalizeSchemaBoolean(value.uniqueItems), uniqueItems: normalizeSchemaBoolean(value.uniqueItems),
@ -622,6 +624,9 @@ function parseSchemaNode(rawNode, displayPath) {
pattern: type === "string" pattern: type === "string"
? metadata.pattern ? metadata.pattern
: undefined, : undefined,
patternRegex: type === "string"
? metadata.patternRegex
: undefined,
enumValues: normalizeSchemaEnumValues(value.enum), enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable 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) { for (let index = 0; index < yamlNode.items.length; index += 1) {
const diagnosticsBeforeValidation = diagnostics.length;
validateNode( validateNode(
schemaNode.items, schemaNode.items,
yamlNode.items[index], yamlNode.items[index],
joinArrayIndexPath(displayPath, index), joinArrayIndexPath(displayPath, index),
diagnostics, diagnostics,
localizer); 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) { if (schemaNode.uniqueItems === true) {
const seenItems = new Map(); const seenItems = new Map();
for (let index = 0; index < yamlNode.items.length; index += 1) { for (const {index, node} of comparableItems) {
const comparableValue = buildComparableNodeValue(schemaNode.items, yamlNode.items[index]); const comparableValue = buildComparableNodeValue(schemaNode.items, node);
if (seenItems.has(comparableValue)) { if (seenItems.has(comparableValue)) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
@ -696,7 +709,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue)) duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue))
}) })
}); });
break; continue;
} }
seenItems.set(comparableValue, index); seenItems.set(comparableValue, index);
@ -830,7 +843,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
} }
if (supportsPatternConstraints && if (supportsPatternConstraints &&
!matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) { !matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, { message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {
@ -920,7 +933,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
return Object.keys(schemaNode.properties) return Object.keys(schemaNode.properties)
.filter((key) => yamlNode.map.has(key)) .filter((key) => yamlNode.map.has(key))
.sort((left, right) => left.localeCompare(right)) .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("|"); .join("|");
} }
@ -929,7 +945,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
return yamlNode.kind; 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") { if (yamlNode.kind !== "scalar") {
@ -942,7 +961,7 @@ function buildComparableNodeValue(schemaNode, yamlNode) {
: schemaNode.type === "boolean" : schemaNode.type === "boolean"
? String(/^true$/iu.test(scalarValue)) ? String(/^true$/iu.test(scalarValue))
: scalarValue; : scalarValue;
return `${schemaNode.type}:${normalizedScalar}`; return `${schemaNode.type}:${normalizedScalar.length}:${normalizedScalar}`;
} }
/** /**
@ -1704,6 +1723,7 @@ module.exports = {
* minLength?: number, * minLength?: number,
* maxLength?: number, * maxLength?: number,
* pattern?: string, * pattern?: string,
* patternRegex?: RegExp,
* enumValues?: string[], * enumValues?: string[],
* refTable?: string * refTable?: string
* }} SchemaNode * }} SchemaNode

View File

@ -349,6 +349,135 @@ phases:
assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u); 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", () => { test("parseSchemaContent should capture scalar range and length metadata", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {