docs(config): 添加游戏内容配置系统完整文档

- 新增 YAML 配置源文件支持说明
- 添加 JSON Schema 结构描述功能介绍
- 实现一对象一文件的目录组织方式
- 提供运行时只读查询机制说明
- 添加 Source Generator 类型生成功能文档
- 集成 VS Code 插件配置浏览功能说明
- 添加跨表引用和校验行为详细说明
- 提供热重载开发期工具使用指南
- 完善 Godot 引擎文本配置桥接文档
- 补充 Architecture 模块接入模板说明
This commit is contained in:
GeWuYou 2026-04-16 09:59:43 +08:00
parent ba15d9d0f6
commit 0f5b3a98bf
10 changed files with 683 additions and 88 deletions

View File

@ -1085,6 +1085,103 @@ public class YamlConfigLoaderTests
}); });
} }
/// <summary>
/// 验证运行时会拒绝命中 <c>not</c> 子 schema 的标量值。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Value_Matches_Not_Schema()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Deprecated
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "hp"],
"properties": {
"id": { "type": "integer" },
"name": {
"type": "string",
"not": {
"type": "string",
"const": "Deprecated"
}
},
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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("name"));
Assert.That(exception.Message, Does.Contain("must not match the 'not' schema"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证 schema 将 <c>not</c> 声明为非对象值时,会在解析阶段被拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Not_Is_Not_An_Object()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "hp"],
"properties": {
"id": { "type": "integer" },
"name": {
"type": "string",
"not": "deprecated"
},
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported));
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("name"));
Assert.That(exception.Message, Does.Contain("must declare 'not' as an object-valued schema"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary> /// <summary>
/// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。 /// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。
/// </summary> /// </summary>

View File

@ -324,42 +324,80 @@ internal static class YamlConfigSchemaValidator
var typeName = typeElement.GetString() ?? string.Empty; var typeName = typeElement.GetString() ?? string.Empty;
var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element);
switch (typeName) var parsedNode = typeName switch
{ {
case "object": "object" => ParseObjectSchemaNode(
EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, tableName,
YamlConfigSchemaPropertyType.Object, schemaPath,
referenceTableName); propertyPath,
return ParseObjectNode(tableName, schemaPath, propertyPath, element, isRoot); element,
referenceTableName,
isRoot),
"array" => ParseArrayNode(tableName, schemaPath, propertyPath, element, referenceTableName),
"integer" => CreateScalarNode(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.Integer,
element,
referenceTableName),
"number" => CreateScalarNode(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.Number,
element,
referenceTableName),
"boolean" => CreateScalarNode(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.Boolean,
element,
referenceTableName),
"string" => CreateScalarNode(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.String,
element,
referenceTableName),
_ => throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath),
rawValue: typeName)
};
return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element));
}
case "array": /// <summary>
return ParseArrayNode(tableName, schemaPath, propertyPath, element, referenceTableName); /// 解析对象类型 schema并在进入对象节点解析前先校验 ref-table 是否兼容。
/// </summary>
case "integer": /// <param name="tableName">所属配置表名称。</param>
return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Integer, /// <param name="schemaPath">Schema 文件路径。</param>
element, referenceTableName); /// <param name="propertyPath">对象属性路径。</param>
/// <param name="element">对象 schema 节点。</param>
case "number": /// <param name="referenceTableName">声明在当前节点上的目标引用表。</param>
return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Number, /// <param name="isRoot">是否为根节点。</param>
element, referenceTableName); /// <returns>对象节点模型。</returns>
private static YamlConfigSchemaNode ParseObjectSchemaNode(
case "boolean": string tableName,
return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Boolean, string schemaPath,
element, referenceTableName); string propertyPath,
JsonElement element,
case "string": string? referenceTableName,
return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.String, bool isRoot)
element, referenceTableName); {
EnsureReferenceKeywordIsSupported(
default: tableName,
throw ConfigLoadExceptionFactory.Create( schemaPath,
ConfigLoadFailureKind.SchemaUnsupported, propertyPath,
tableName, YamlConfigSchemaPropertyType.Object,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.", referenceTableName);
schemaPath: schemaPath, return ParseObjectNode(tableName, schemaPath, propertyPath, element, isRoot);
displayPath: GetDiagnosticPath(propertyPath),
rawValue: typeName);
}
} }
/// <summary> /// <summary>
@ -522,6 +560,57 @@ internal static class YamlConfigSchemaValidator
ParseConstantValue(tableName, schemaPath, propertyPath, element, scalarNode)); ParseConstantValue(tableName, schemaPath, propertyPath, element, scalarNode));
} }
/// <summary>
/// 解析节点上的 <c>not</c> 约束。
/// 该子 schema 继续复用同一套节点解析逻辑,保证 Runtime / Generator / Tooling
/// 对深层结构与格式白名单的解释保持一致。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点路径。</param>
/// <param name="element">Schema JSON 节点。</param>
/// <returns>解析后的 negated schema未声明时返回空。</returns>
private static YamlConfigSchemaNode? ParseNegatedSchemaNode(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (!element.TryGetProperty("not", out var notElement))
{
return null;
}
if (notElement.ValueKind != JsonValueKind.Object)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'not' as an object-valued schema.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return ParseNode(
tableName,
schemaPath,
BuildNestedSchemaPath(propertyPath, "not"),
notElement);
}
/// <summary>
/// 为 <c>contains</c> / <c>not</c> 这类内联子 schema 构建稳定的诊断路径。
/// </summary>
/// <param name="propertyPath">当前节点路径。</param>
/// <param name="suffix">内联子 schema 后缀。</param>
/// <returns>带内联后缀的 schema 路径。</returns>
private static string BuildNestedSchemaPath(string propertyPath, string suffix)
{
return string.IsNullOrWhiteSpace(propertyPath)
? $"[{suffix}]"
: $"{propertyPath}[{suffix}]";
}
/// <summary> /// <summary>
/// 递归校验 YAML 节点。 /// 递归校验 YAML 节点。
/// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。 /// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。
@ -706,6 +795,7 @@ internal static class YamlConfigSchemaValidator
} }
ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode);
ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, mappingNode, schemaNode);
} }
/// <summary> /// <summary>
@ -828,6 +918,7 @@ internal static class YamlConfigSchemaValidator
ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references); ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references);
ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
} }
/// <summary> /// <summary>
@ -921,6 +1012,7 @@ internal static class YamlConfigSchemaValidator
} }
ValidateConstantValue(tableName, yamlPath, displayPath, scalarNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, scalarNode, schemaNode);
ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, scalarNode, schemaNode);
if (schemaNode.ReferenceTableName != null && if (schemaNode.ReferenceTableName != null &&
references is not null) references is not null)
@ -2644,13 +2736,14 @@ internal static class YamlConfigSchemaValidator
var matchingCount = 0; var matchingCount = 0;
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
{ {
if (IsArrayItemMatchingContains( if (TryMatchSchemaNode(
tableName, tableName,
yamlPath, yamlPath,
$"{displayPath}[{itemIndex}]", $"{displayPath}[{itemIndex}]",
sequenceNode.Children[itemIndex], sequenceNode.Children[itemIndex],
containsNode, containsNode,
references)) references,
allowUnknownObjectProperties: true))
{ {
matchingCount++; matchingCount++;
} }
@ -2660,26 +2753,29 @@ internal static class YamlConfigSchemaValidator
} }
/// <summary> /// <summary>
/// 判断单个数组元素是否满足 <c>contains</c> 子 schema。 /// 判断当前 YAML 节点是否满足给定 schema 子树。
/// contains 的语义是“尝试匹配”,因此普通约束失败会返回 <see langword="false" />,但内部意外状态仍会继续抛出。 /// contains / not 都通过该路径复用主校验逻辑,因此普通约束失败会返回 <see langword="false" />
/// 但内部意外状态仍会继续抛出。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param> /// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param> /// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前数组元素路径。</param> /// <param name="displayPath">当前节点路径。</param>
/// <param name="itemNode">实际 YAML 元素。</param> /// <param name="node">实际 YAML 节点。</param>
/// <param name="containsNode">contains 子 schema。</param> /// <param name="schemaNode">要试匹配的 schema 子树。</param>
/// <param name="references">当前元素匹配成功后要写回的可选跨表引用收集器。</param> /// <param name="references">当前节点匹配成功后要写回的可选跨表引用收集器。</param>
/// <returns>当前元素是否匹配 contains 子 schema。</returns> /// <param name="allowUnknownObjectProperties">对象试匹配时是否允许额外字段。</param>
private static bool IsArrayItemMatchingContains( /// <returns>当前节点是否匹配指定 schema 子树。</returns>
private static bool TryMatchSchemaNode(
string tableName, string tableName,
string yamlPath, string yamlPath,
string displayPath, string displayPath,
YamlNode itemNode, YamlNode node,
YamlConfigSchemaNode containsNode, YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references) ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties)
{ {
// contains 的“试匹配”不能把失败元素的引用泄漏给外层,但匹配成功的元素仍需要参与 // 约束试匹配不能把失败路径的引用泄漏给外层,但匹配成功的分支仍需要把引用写回,
// 跨表引用收集,否则仅声明在 contains 子 schema 里的 ref-table 会被运行时遗漏 // 这样 contains / not 等内联 schema 才能与主校验链复用同一套递归解释规则
List<YamlConfigReferenceUsage>? matchedReferences = references is null ? null : new(); List<YamlConfigReferenceUsage>? matchedReferences = references is null ? null : new();
try try
@ -2688,10 +2784,10 @@ internal static class YamlConfigSchemaValidator
tableName, tableName,
yamlPath, yamlPath,
displayPath, displayPath,
itemNode, node,
containsNode, schemaNode,
matchedReferences, matchedReferences,
allowUnknownObjectProperties: true); allowUnknownObjectProperties);
if (references is not null && if (references is not null &&
matchedReferences is not null) matchedReferences is not null)
@ -2711,6 +2807,49 @@ internal static class YamlConfigSchemaValidator
} }
} }
/// <summary>
/// 校验节点是否命中了 <c>not</c> 声明的禁用 schema。
/// 与 contains 不同not 会沿用主校验链的严格对象语义,避免把“声明属性子集”误当成完整命中。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前字段路径。</param>
/// <param name="node">当前 YAML 节点。</param>
/// <param name="schemaNode">当前 schema 节点。</param>
private static void ValidateNegatedSchemaConstraint(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode)
{
if (schemaNode.NegatedSchemaNode is null ||
!TryMatchSchemaNode(
tableName,
yamlPath,
displayPath,
node,
schemaNode.NegatedSchemaNode,
references: null,
allowUnknownObjectProperties: false))
{
return;
}
var subject = string.IsNullOrWhiteSpace(displayPath)
? "Root object"
: $"Property '{displayPath}'";
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must not match the 'not' schema.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode.NegatedSchemaNode),
detail: "The current YAML value matches the forbidden 'not' schema.");
}
/// <summary> /// <summary>
/// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。 /// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。
/// 该键同时服务于 <c>uniqueItems</c> 与 <c>const</c> /// 该键同时服务于 <c>uniqueItems</c> 与 <c>const</c>
@ -3313,6 +3452,7 @@ internal sealed class YamlConfigSchemaNode
ArrayConstraints = validation.ArrayConstraints; ArrayConstraints = validation.ArrayConstraints;
ObjectConstraints = validation.ObjectConstraints; ObjectConstraints = validation.ObjectConstraints;
ConstantValue = validation.ConstantValue; ConstantValue = validation.ConstantValue;
NegatedSchemaNode = validation.NegatedSchemaNode;
SchemaPathHint = schemaPathHint; SchemaPathHint = schemaPathHint;
} }
@ -3366,6 +3506,11 @@ internal sealed class YamlConfigSchemaNode
/// </summary> /// </summary>
public YamlConfigConstantValue? ConstantValue { get; } public YamlConfigConstantValue? ConstantValue { get; }
/// <summary>
/// 获取节点声明的 <c>not</c> 子 schema未声明时返回空。
/// </summary>
public YamlConfigSchemaNode? NegatedSchemaNode { get; }
/// <summary> /// <summary>
/// 获取用于诊断显示的 schema 路径提示。 /// 获取用于诊断显示的 schema 路径提示。
/// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。 /// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。
@ -3395,7 +3540,8 @@ internal sealed class YamlConfigSchemaNode
constraints: null, constraints: null,
arrayConstraints: null, arrayConstraints: null,
objectConstraints, objectConstraints,
constantValue: null), constantValue: null,
negatedSchemaNode: null),
schemaPathHint); schemaPathHint);
} }
@ -3420,7 +3566,8 @@ internal sealed class YamlConfigSchemaNode
constraints: null, constraints: null,
arrayConstraints, arrayConstraints,
objectConstraints: null, objectConstraints: null,
constantValue: null), constantValue: null,
negatedSchemaNode: null),
schemaPathHint); schemaPathHint);
} }
@ -3449,7 +3596,8 @@ internal sealed class YamlConfigSchemaNode
constraints, constraints,
arrayConstraints: null, arrayConstraints: null,
objectConstraints: null, objectConstraints: null,
constantValue: null), constantValue: null,
negatedSchemaNode: null),
schemaPathHint); schemaPathHint);
} }
@ -3482,6 +3630,20 @@ internal sealed class YamlConfigSchemaNode
SchemaPathHint); SchemaPathHint);
} }
/// <summary>
/// 基于当前节点复制一个只替换 <c>not</c> 子 schema 的新节点。
/// </summary>
/// <param name="negatedSchemaNode">新的 negated schema。</param>
/// <returns>复制后的节点。</returns>
public YamlConfigSchemaNode WithNegatedSchemaNode(YamlConfigSchemaNode? negatedSchemaNode)
{
return new YamlConfigSchemaNode(
NodeType,
_children,
_validation.WithNegatedSchemaNode(negatedSchemaNode),
SchemaPathHint);
}
private sealed class NodeChildren private sealed class NodeChildren
{ {
public NodeChildren( public NodeChildren(
@ -3511,7 +3673,8 @@ internal sealed class YamlConfigSchemaNode
YamlConfigScalarConstraints? constraints, YamlConfigScalarConstraints? constraints,
YamlConfigArrayConstraints? arrayConstraints, YamlConfigArrayConstraints? arrayConstraints,
YamlConfigObjectConstraints? objectConstraints, YamlConfigObjectConstraints? objectConstraints,
YamlConfigConstantValue? constantValue) YamlConfigConstantValue? constantValue,
YamlConfigSchemaNode? negatedSchemaNode)
{ {
ReferenceTableName = referenceTableName; ReferenceTableName = referenceTableName;
AllowedValues = allowedValues; AllowedValues = allowedValues;
@ -3519,6 +3682,7 @@ internal sealed class YamlConfigSchemaNode
ArrayConstraints = arrayConstraints; ArrayConstraints = arrayConstraints;
ObjectConstraints = objectConstraints; ObjectConstraints = objectConstraints;
ConstantValue = constantValue; ConstantValue = constantValue;
NegatedSchemaNode = negatedSchemaNode;
} }
public static NodeValidation None { get; } = new( public static NodeValidation None { get; } = new(
@ -3527,7 +3691,8 @@ internal sealed class YamlConfigSchemaNode
constraints: null, constraints: null,
arrayConstraints: null, arrayConstraints: null,
objectConstraints: null, objectConstraints: null,
constantValue: null); constantValue: null,
negatedSchemaNode: null);
public string? ReferenceTableName { get; } public string? ReferenceTableName { get; }
@ -3541,16 +3706,24 @@ internal sealed class YamlConfigSchemaNode
public YamlConfigConstantValue? ConstantValue { get; } public YamlConfigConstantValue? ConstantValue { get; }
public YamlConfigSchemaNode? NegatedSchemaNode { get; }
public NodeValidation WithReferenceTable(string referenceTableName) public NodeValidation WithReferenceTable(string referenceTableName)
{ {
return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints, return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints,
ObjectConstraints, ConstantValue); ObjectConstraints, ConstantValue, NegatedSchemaNode);
} }
public NodeValidation WithConstantValue(YamlConfigConstantValue? constantValue) public NodeValidation WithConstantValue(YamlConfigConstantValue? constantValue)
{ {
return new NodeValidation(ReferenceTableName, AllowedValues, Constraints, ArrayConstraints, return new NodeValidation(ReferenceTableName, AllowedValues, Constraints, ArrayConstraints,
ObjectConstraints, constantValue); ObjectConstraints, constantValue, NegatedSchemaNode);
}
public NodeValidation WithNegatedSchemaNode(YamlConfigSchemaNode? negatedSchemaNode)
{
return new NodeValidation(ReferenceTableName, AllowedValues, Constraints, ArrayConstraints,
ObjectConstraints, ConstantValue, negatedSchemaNode);
} }
} }
} }

View File

@ -272,6 +272,102 @@ public class SchemaConfigGeneratorTests
}); });
} }
/// <summary>
/// 验证 <c>not</c> 子 schema 的约束会写入生成 XML 文档。
/// </summary>
[Test]
public void Run_Should_Write_Not_Constraint_Into_Generated_Documentation()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": {
"type": "string",
"not": {
"type": "string",
"const": "Deprecated"
}
}
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
var generatedSources = result.Results
.Single()
.GeneratedSources
.ToDictionary(
static sourceResult => sourceResult.HintName,
static sourceResult => sourceResult.SourceText.ToString(),
StringComparer.Ordinal);
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
Assert.That(generatedSources["MonsterConfig.g.cs"],
Does.Contain("Constraints: not = string (const = \"Deprecated\")."));
}
/// <summary>
/// 验证 <c>not</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Not_Schema_Uses_Format_On_Non_String_Node()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "hp"],
"properties": {
"id": { "type": "integer" },
"hp": {
"type": "integer",
"not": {
"type": "integer",
"format": "uuid"
}
}
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
var diagnostic = result.Results.Single().Diagnostics.Single();
Assert.Multiple(() =>
{
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_009"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("hp[not]"));
Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'."));
});
}
/// <summary> /// <summary>
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
/// </summary> /// </summary>

View File

@ -693,6 +693,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return false; return false;
} }
if (element.TryGetProperty("not", out var notElement) &&
notElement.ValueKind == JsonValueKind.Object &&
!TryValidateStringFormatMetadataRecursively(
filePath,
$"{displayPath}[not]",
notElement,
out diagnostic))
{
return false;
}
return true; return true;
} }
@ -2888,6 +2899,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
} }
} }
var notDocumentation = TryBuildNotDocumentation(element);
if (notDocumentation is not null)
{
parts.Add($"not = {notDocumentation}");
}
if (schemaType == "array" && if (schemaType == "array" &&
TryGetNonNegativeInt32(element, "minContains", out var minContains)) TryGetNonNegativeInt32(element, "minContains", out var minContains))
{ {
@ -2929,18 +2946,34 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return null; return null;
} }
return TryBuildContainsSchemaSummary(containsElement); return TryBuildInlineSchemaSummary(containsElement);
} }
/// <summary> /// <summary>
/// 为 <c>contains</c> 子 schema 生成紧凑摘要。 /// 将 <c>not</c> 子 schema 整理成 XML 文档可读字符串。
/// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains 与主属性文档逐渐漂移。
/// </summary> /// </summary>
/// <param name="containsElement">contains 子 schema。</param> /// <param name="element">Schema 节点。</param>
/// <returns>格式化后的摘要字符串。</returns> /// <returns>格式化后的 not 说明。</returns>
private static string? TryBuildContainsSchemaSummary(JsonElement containsElement) private static string? TryBuildNotDocumentation(JsonElement element)
{ {
if (!containsElement.TryGetProperty("type", out var typeElement) || if (!element.TryGetProperty("not", out var notElement) ||
notElement.ValueKind != JsonValueKind.Object)
{
return null;
}
return TryBuildInlineSchemaSummary(notElement);
}
/// <summary>
/// 为内联子 schema 生成紧凑摘要。
/// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains / not 与主属性文档逐渐漂移。
/// </summary>
/// <param name="schemaElement">内联子 schema。</param>
/// <returns>格式化后的摘要字符串。</returns>
private static string? TryBuildInlineSchemaSummary(JsonElement schemaElement)
{
if (!schemaElement.TryGetProperty("type", out var typeElement) ||
typeElement.ValueKind != JsonValueKind.String) typeElement.ValueKind != JsonValueKind.String)
{ {
return null; return null;
@ -2953,19 +2986,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
} }
var details = new List<string>(); var details = new List<string>();
var enumDocumentation = TryBuildEnumDocumentation(containsElement, schemaType!); var enumDocumentation = TryBuildEnumDocumentation(schemaElement, schemaType!);
if (enumDocumentation is not null) if (enumDocumentation is not null)
{ {
details.Add($"enum = {enumDocumentation}"); details.Add($"enum = {enumDocumentation}");
} }
var constraintDocumentation = TryBuildConstraintDocumentation(containsElement, schemaType!); var constraintDocumentation = TryBuildConstraintDocumentation(schemaElement, schemaType!);
if (constraintDocumentation is not null) if (constraintDocumentation is not null)
{ {
details.Add(constraintDocumentation); details.Add(constraintDocumentation);
} }
var refTable = TryGetMetadataString(containsElement, "x-gframework-ref-table"); var refTable = TryGetMetadataString(schemaElement, "x-gframework-ref-table");
if (!string.IsNullOrWhiteSpace(refTable)) if (!string.IsNullOrWhiteSpace(refTable))
{ {
details.Add($"ref-table = {refTable}"); details.Add($"ref-table = {refTable}");

View File

@ -12,7 +12,7 @@
- JSON Schema 作为结构描述 - JSON Schema 作为结构描述
- 一对象一文件的目录组织 - 一对象一文件的目录组织
- 运行时只读查询 - 运行时只读查询
- Runtime / Generator / Tooling 共享支持 `const``minimum`、`maximum``exclusiveMinimum``exclusiveMaximum``multipleOf``minLength``maxLength``pattern``format`(当前稳定子集:`date``date-time``duration``email``time``uri``uuid`)、`minItems``maxItems``uniqueItems``contains``minContains``maxContains``minProperties``maxProperties` - Runtime / Generator / Tooling 共享支持 `const``not`、`minimum`、`maximum``exclusiveMinimum``exclusiveMaximum``multipleOf``minLength``maxLength``pattern``format`(当前稳定子集:`date``date-time``duration``email``time``uri``uuid`)、`minItems``maxItems``uniqueItems``contains``minContains``maxContains``minProperties``maxProperties`
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
@ -720,6 +720,7 @@ var loader = new YamlConfigLoader("config-root")
- 数组字段违反 `contains` / `minContains` / `maxContains` - 数组字段违反 `contains` / `minContains` / `maxContains`
- 对象字段违反 `minProperties` / `maxProperties` - 对象字段违反 `minProperties` / `maxProperties`
- 标量 / 对象 / 数组字段违反 `const` - 标量 / 对象 / 数组字段违反 `const`
- 标量 / 对象 / 数组字段命中 `not`
- 标量 `enum` 不匹配 - 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配 - 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@ -767,6 +768,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `description`:供表单提示、生成代码 XML 文档和接入说明复用 - `description`:供表单提示、生成代码 XML 文档和接入说明复用
- `default`:供生成类型属性初始值和工具提示复用 - `default`:供生成类型属性初始值和工具提示复用
- `const`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象会忽略字段顺序比较,数组保留元素顺序,标量按运行时同一套类型归一化规则比较 - `const`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象会忽略字段顺序比较,数组保留元素顺序,标量按运行时同一套类型归一化规则比较
- `not`供运行时校验、VS Code 校验和生成代码 XML 文档复用;`not` 子 schema 会复用同一套递归校验规则,但对象匹配保持主校验链的严格语义,不会像 `contains` 那样把“声明属性子集”视为命中
- `enum`供运行时校验、VS Code 校验和表单枚举选择复用 - `enum`供运行时校验、VS Code 校验和表单枚举选择复用
- `minimum` / `maximum`供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `minimum` / `maximum`供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `exclusiveMinimum` / `exclusiveMaximum`供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `exclusiveMinimum` / `exclusiveMaximum`供运行时校验、VS Code 校验和生成代码 XML 文档复用

View File

@ -1071,6 +1071,7 @@ function parseSchemaNode(rawNode, displayPath) {
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 patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath); const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath);
const negatedSchemaNode = parseNegatedSchemaNode(value.not, 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,
@ -1115,7 +1116,8 @@ function parseSchemaNode(rawNode, displayPath) {
maxProperties: metadata.maxProperties, maxProperties: metadata.maxProperties,
title: metadata.title, title: metadata.title,
description: metadata.description, description: metadata.description,
defaultValue: metadata.defaultValue defaultValue: metadata.defaultValue,
not: negatedSchemaNode
}, value.const, displayPath); }, value.const, displayPath);
} }
@ -1159,7 +1161,8 @@ function parseSchemaNode(rawNode, displayPath) {
uniqueItems: metadata.uniqueItems === true, uniqueItems: metadata.uniqueItems === true,
refTable: metadata.refTable, refTable: metadata.refTable,
contains: containsNode, contains: containsNode,
items: itemNode items: itemNode,
not: negatedSchemaNode
}, value.const, displayPath); }, value.const, displayPath);
} }
@ -1200,10 +1203,31 @@ function parseSchemaNode(rawNode, displayPath) {
? metadata.format ? metadata.format
: undefined, : undefined,
enumValues: normalizeSchemaEnumValues(value.enum), enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable refTable: metadata.refTable,
not: negatedSchemaNode
}, value.const, displayPath); }, value.const, displayPath);
} }
/**
* Parse one optional `not` sub-schema and keep path formatting aligned with
* the runtime/generator diagnostics.
*
* @param {unknown} rawNot Raw `not` node.
* @param {string} displayPath Parent schema path.
* @returns {SchemaNode | undefined} Parsed negated schema node.
*/
function parseNegatedSchemaNode(rawNot, displayPath) {
if (rawNot === undefined) {
return undefined;
}
if (!rawNot || typeof rawNot !== "object" || Array.isArray(rawNot)) {
throw new Error(`Schema property '${displayPath}' must declare 'not' as an object-valued schema.`);
}
return parseSchemaNode(rawNot, `${displayPath}[not]`);
}
/** /**
* Validate one schema node against one YAML node. * Validate one schema node against one YAML node.
* *
@ -1299,7 +1323,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
if (!hasStructurallyInvalidArrayItems && schemaNode.contains) { if (!hasStructurallyInvalidArrayItems && schemaNode.contains) {
let matchingContainsCount = 0; let matchingContainsCount = 0;
for (const {node} of containsCandidateItems) { for (const {node} of containsCandidateItems) {
if (matchesSchemaNode(schemaNode.contains, node)) { if (matchesSchemaNode(schemaNode.contains, node, true)) {
matchingContainsCount += 1; matchingContainsCount += 1;
} }
} }
@ -1330,6 +1354,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
} }
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer);
return; return;
} }
@ -1480,6 +1505,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
} }
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer);
} }
/** /**
@ -1561,6 +1587,7 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
} }
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer);
} }
/** /**
@ -1571,10 +1598,12 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
* *
* @param {SchemaNode} schemaNode Schema node. * @param {SchemaNode} schemaNode Schema node.
* @param {YamlNode} yamlNode YAML node. * @param {YamlNode} yamlNode YAML node.
* @param {boolean} allowUnknownObjectProperties Whether object matching should
* tolerate extra undeclared properties.
* @returns {boolean} True when the YAML node matches the schema node. * @returns {boolean} True when the YAML node matches the schema node.
*/ */
function matchesSchemaNode(schemaNode, yamlNode) { function matchesSchemaNode(schemaNode, yamlNode, allowUnknownObjectProperties = false) {
return matchesSchemaNodeInternal(schemaNode, yamlNode); return matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectProperties);
} }
/** /**
@ -1584,9 +1613,11 @@ function matchesSchemaNode(schemaNode, yamlNode) {
* *
* @param {SchemaNode} schemaNode Schema node. * @param {SchemaNode} schemaNode Schema node.
* @param {YamlNode} yamlNode YAML node. * @param {YamlNode} yamlNode YAML node.
* @param {boolean} allowUnknownObjectProperties Whether object matching should
* tolerate extra undeclared properties.
* @returns {boolean} True when the YAML node satisfies the schema node. * @returns {boolean} True when the YAML node satisfies the schema node.
*/ */
function matchesSchemaNodeInternal(schemaNode, yamlNode) { function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectProperties) {
if (schemaNode.type === "object") { if (schemaNode.type === "object") {
if (!yamlNode || yamlNode.kind !== "object") { if (!yamlNode || yamlNode.kind !== "object") {
return false; return false;
@ -1604,9 +1635,17 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
} }
} }
if (!allowUnknownObjectProperties) {
for (const entry of yamlNode.entries) {
if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) {
return false;
}
}
}
for (const [key, childSchema] of Object.entries(schemaNode.properties)) { for (const [key, childSchema] of Object.entries(schemaNode.properties)) {
if (yamlNode.map.has(key) && if (yamlNode.map.has(key) &&
!matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key))) { !matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key), allowUnknownObjectProperties)) {
return false; return false;
} }
} }
@ -1621,8 +1660,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
return false; return false;
} }
return typeof schemaNode.constComparableValue !== "string" || if (typeof schemaNode.constComparableValue === "string" &&
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) {
return false;
}
return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false);
} }
if (schemaNode.type === "array") { if (schemaNode.type === "array") {
@ -1641,7 +1684,7 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
} }
for (const item of yamlNode.items) { for (const item of yamlNode.items) {
if (!matchesSchemaNodeInternal(schemaNode.items, item)) { if (!matchesSchemaNodeInternal(schemaNode.items, item, allowUnknownObjectProperties)) {
return false; return false;
} }
} }
@ -1661,7 +1704,7 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
if (schemaNode.contains) { if (schemaNode.contains) {
let matchingContainsCount = 0; let matchingContainsCount = 0;
for (const item of yamlNode.items) { for (const item of yamlNode.items) {
if (matchesSchemaNodeInternal(schemaNode.contains, item)) { if (matchesSchemaNodeInternal(schemaNode.contains, item, true)) {
matchingContainsCount += 1; matchingContainsCount += 1;
} }
} }
@ -1679,8 +1722,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
} }
} }
return typeof schemaNode.constComparableValue !== "string" || if (typeof schemaNode.constComparableValue === "string" &&
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) {
return false;
}
return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false);
} }
if (!yamlNode || yamlNode.kind !== "scalar") { if (!yamlNode || yamlNode.kind !== "scalar") {
@ -1753,8 +1800,36 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
return false; return false;
} }
return typeof schemaNode.constComparableValue !== "string" || if (typeof schemaNode.constComparableValue === "string" &&
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) {
return false;
}
return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false);
}
/**
* Emit one validation error when the current YAML node matches a forbidden `not`
* sub-schema. Unlike `contains`, this path keeps object matching strict so
* undeclared members still block the negated branch from matching.
*
* @param {SchemaNode} schemaNode Schema node.
* @param {YamlNode} yamlNode YAML node.
* @param {string} displayPath Current logical path.
* @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
*/
function validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
if (!schemaNode.not || !matchesSchemaNode(schemaNode.not, yamlNode, false)) {
return;
}
diagnostics.push({
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.notViolation, localizer, {
displayPath
})
});
} }
/** /**
@ -1962,6 +2037,8 @@ function localizeValidationMessage(key, localizer, params) {
return `属性“${params.displayPath}”必须大于或等于 ${params.value}`; return `属性“${params.displayPath}”必须大于或等于 ${params.value}`;
case ValidationMessageKeys.multipleOfViolation: case ValidationMessageKeys.multipleOfViolation:
return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`; return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`;
case ValidationMessageKeys.notViolation:
return `属性“${params.displayPath}”不能匹配被 \`not\` 禁止的 schema。`;
case ValidationMessageKeys.minContainsViolation: case ValidationMessageKeys.minContainsViolation:
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个匹配 contains 条件的元素。`; return `属性“${params.displayPath}”至少需要包含 ${params.value} 个匹配 contains 条件的元素。`;
case ValidationMessageKeys.minItemsViolation: case ValidationMessageKeys.minItemsViolation:
@ -2010,6 +2087,8 @@ function localizeValidationMessage(key, localizer, params) {
return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`;
case ValidationMessageKeys.multipleOfViolation: case ValidationMessageKeys.multipleOfViolation:
return `Property '${params.displayPath}' must be a multiple of ${params.value}.`; return `Property '${params.displayPath}' must be a multiple of ${params.value}.`;
case ValidationMessageKeys.notViolation:
return `Property '${params.displayPath}' must not match the forbidden 'not' schema.`;
case ValidationMessageKeys.minContainsViolation: case ValidationMessageKeys.minContainsViolation:
return `Property '${params.displayPath}' must contain at least ${params.value} items matching the 'contains' schema.`; return `Property '${params.displayPath}' must contain at least ${params.value} items matching the 'contains' schema.`;
case ValidationMessageKeys.minItemsViolation: case ValidationMessageKeys.minItemsViolation:
@ -2727,7 +2806,8 @@ module.exports = {
* defaultValue?: string, * defaultValue?: string,
* constValue?: string, * constValue?: string,
* constDisplayValue?: string, * constDisplayValue?: string,
* constComparableValue?: string * constComparableValue?: string,
* not?: SchemaNode
* } | { * } | {
* type: "array", * type: "array",
* displayPath: string, * displayPath: string,
@ -2744,6 +2824,7 @@ module.exports = {
* uniqueItems?: boolean, * uniqueItems?: boolean,
* refTable?: string, * refTable?: string,
* contains?: SchemaNode, * contains?: SchemaNode,
* not?: SchemaNode,
* items: SchemaNode * items: SchemaNode
* } | { * } | {
* type: "string" | "integer" | "number" | "boolean", * type: "string" | "integer" | "number" | "boolean",
@ -2765,7 +2846,8 @@ module.exports = {
* patternRegex?: RegExp, * patternRegex?: RegExp,
* format?: string, * format?: string,
* enumValues?: string[], * enumValues?: string[],
* refTable?: string * refTable?: string,
* not?: SchemaNode
* }} SchemaNode * }} SchemaNode
*/ */

View File

@ -148,6 +148,7 @@ const enMessages = {
[ValidationMessageKeys.maxPropertiesViolation]: "Property '{displayPath}' must contain at most {value} properties.", [ValidationMessageKeys.maxPropertiesViolation]: "Property '{displayPath}' must contain at most {value} properties.",
[ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.",
[ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.", [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.",
[ValidationMessageKeys.notViolation]: "Property '{displayPath}' must not match the forbidden 'not' schema.",
[ValidationMessageKeys.minContainsViolation]: "Property '{displayPath}' must contain at least {value} items matching the 'contains' schema.", [ValidationMessageKeys.minContainsViolation]: "Property '{displayPath}' must contain at least {value} items matching the 'contains' schema.",
[ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.",
[ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.",
@ -266,6 +267,7 @@ const zhCnMessages = {
[ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。", [ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。",
[ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。",
[ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。", [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。",
[ValidationMessageKeys.notViolation]: "属性“{displayPath}”不能匹配被 `not` 禁止的 schema。",
[ValidationMessageKeys.minContainsViolation]: "属性“{displayPath}”至少需要包含 {value} 个匹配 contains 条件的元素。", [ValidationMessageKeys.minContainsViolation]: "属性“{displayPath}”至少需要包含 {value} 个匹配 contains 条件的元素。",
[ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。",
[ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。",

View File

@ -15,6 +15,7 @@ const ValidationMessageKeys = Object.freeze({
maxPropertiesViolation: "validation.maxPropertiesViolation", maxPropertiesViolation: "validation.maxPropertiesViolation",
minimumViolation: "validation.minimumViolation", minimumViolation: "validation.minimumViolation",
multipleOfViolation: "validation.multipleOfViolation", multipleOfViolation: "validation.multipleOfViolation",
notViolation: "validation.notViolation",
minContainsViolation: "validation.minContainsViolation", minContainsViolation: "validation.minContainsViolation",
minItemsViolation: "validation.minItemsViolation", minItemsViolation: "validation.minItemsViolation",
minLengthViolation: "validation.minLengthViolation", minLengthViolation: "validation.minLengthViolation",

View File

@ -1624,6 +1624,43 @@ test("parseSchemaContent should capture object property-count metadata", () => {
assert.equal(schema.properties.reward.maxProperties, 2); assert.equal(schema.properties.reward.maxProperties, 2);
}); });
test("parseSchemaContent should capture not sub-schema metadata", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"name": {
"type": "string",
"not": {
"type": "string",
"const": "Deprecated"
}
}
}
}
`);
assert.equal(schema.properties.name.not.type, "string");
assert.equal(schema.properties.name.not.constDisplayValue, "\"Deprecated\"");
});
test("parseSchemaContent should reject non-object not declarations", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"name": {
"type": "string",
"not": "deprecated"
}
}
}
`),
/must declare 'not' as an object-valued schema/u
);
});
test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => { test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
assert.throws( assert.throws(
() => parseSchemaContent(` () => parseSchemaContent(`
@ -1759,6 +1796,66 @@ reward: 1
assert.equal(diagnostics[0].message, "属性“reward”应为对象。"); assert.equal(diagnostics[0].message, "属性“reward”应为对象。");
}); });
test("validateParsedConfig should reject values that match a forbidden not schema", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"name": {
"type": "string",
"not": {
"type": "string",
"const": "Deprecated"
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
name: Deprecated
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1);
assert.equal(
diagnostics[0].message,
"Property 'name' must not match the forbidden 'not' schema.");
});
test("validateParsedConfig should keep not object matching strict instead of contains-style subset matching", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"not": {
"type": "object",
"required": ["gold"],
"properties": {
"gold": { "type": "integer" }
}
},
"properties": {
"gold": { "type": "integer" },
"bonus": { "type": "integer" }
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward:
gold: 10
bonus: 5
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.deepEqual(diagnostics, []);
});
test("applyFormUpdates should update nested scalar and scalar-array paths", () => { test("applyFormUpdates should update nested scalar and scalar-array paths", () => {
const updated = applyFormUpdates( const updated = applyFormUpdates(
[ [

View File

@ -69,3 +69,15 @@ test("createLocalizer should expose contains-count validation keys", () => {
chineseLocalizer.t(ValidationMessageKeys.maxContainsViolation, {displayPath: "dropRates", value: 1}), chineseLocalizer.t(ValidationMessageKeys.maxContainsViolation, {displayPath: "dropRates", value: 1}),
"属性“dropRates”最多只能包含 1 个匹配 contains 条件的元素。"); "属性“dropRates”最多只能包含 1 个匹配 contains 条件的元素。");
}); });
test("createLocalizer should expose not validation keys", () => {
const englishLocalizer = createLocalizer("en");
const chineseLocalizer = createLocalizer("zh-cn");
assert.equal(
englishLocalizer.t(ValidationMessageKeys.notViolation, {displayPath: "name"}),
"Property 'name' must not match the forbidden 'not' schema.");
assert.equal(
chineseLocalizer.t(ValidationMessageKeys.notViolation, {displayPath: "name"}),
"属性“name”不能匹配被 `not` 禁止的 schema。");
});