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>
/// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。
/// </summary>

View File

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

View File

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

View File

@ -12,7 +12,7 @@
- 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 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
@ -720,6 +720,7 @@ var loader = new YamlConfigLoader("config-root")
- 数组字段违反 `contains` / `minContains` / `maxContains`
- 对象字段违反 `minProperties` / `maxProperties`
- 标量 / 对象 / 数组字段违反 `const`
- 标量 / 对象 / 数组字段命中 `not`
- 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@ -767,6 +768,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `description`:供表单提示、生成代码 XML 文档和接入说明复用
- `default`:供生成类型属性初始值和工具提示复用
- `const`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象会忽略字段顺序比较,数组保留元素顺序,标量按运行时同一套类型归一化规则比较
- `not`供运行时校验、VS Code 校验和生成代码 XML 文档复用;`not` 子 schema 会复用同一套递归校验规则,但对象匹配保持主校验链的严格语义,不会像 `contains` 那样把“声明属性子集”视为命中
- `enum`供运行时校验、VS Code 校验和表单枚举选择复用
- `minimum` / `maximum`供运行时校验、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 patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath);
const negatedSchemaNode = parseNegatedSchemaNode(value.not, displayPath);
const metadata = {
title: typeof value.title === "string" ? value.title : undefined,
description: typeof value.description === "string" ? value.description : undefined,
@ -1115,7 +1116,8 @@ function parseSchemaNode(rawNode, displayPath) {
maxProperties: metadata.maxProperties,
title: metadata.title,
description: metadata.description,
defaultValue: metadata.defaultValue
defaultValue: metadata.defaultValue,
not: negatedSchemaNode
}, value.const, displayPath);
}
@ -1159,7 +1161,8 @@ function parseSchemaNode(rawNode, displayPath) {
uniqueItems: metadata.uniqueItems === true,
refTable: metadata.refTable,
contains: containsNode,
items: itemNode
items: itemNode,
not: negatedSchemaNode
}, value.const, displayPath);
}
@ -1200,10 +1203,31 @@ function parseSchemaNode(rawNode, displayPath) {
? metadata.format
: undefined,
enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable
refTable: metadata.refTable,
not: negatedSchemaNode
}, 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.
*
@ -1299,7 +1323,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
if (!hasStructurallyInvalidArrayItems && schemaNode.contains) {
let matchingContainsCount = 0;
for (const {node} of containsCandidateItems) {
if (matchesSchemaNode(schemaNode.contains, node)) {
if (matchesSchemaNode(schemaNode.contains, node, true)) {
matchingContainsCount += 1;
}
}
@ -1330,6 +1354,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
}
validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer);
validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer);
return;
}
@ -1480,6 +1505,7 @@ function validateNode(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);
validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer);
}
/**
@ -1571,10 +1598,12 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
*
* @param {SchemaNode} schemaNode Schema 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.
*/
function matchesSchemaNode(schemaNode, yamlNode) {
return matchesSchemaNodeInternal(schemaNode, yamlNode);
function matchesSchemaNode(schemaNode, yamlNode, allowUnknownObjectProperties = false) {
return matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectProperties);
}
/**
@ -1584,9 +1613,11 @@ function matchesSchemaNode(schemaNode, yamlNode) {
*
* @param {SchemaNode} schemaNode Schema 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.
*/
function matchesSchemaNodeInternal(schemaNode, yamlNode) {
function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectProperties) {
if (schemaNode.type === "object") {
if (!yamlNode || yamlNode.kind !== "object") {
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)) {
if (yamlNode.map.has(key) &&
!matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key))) {
!matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key), allowUnknownObjectProperties)) {
return false;
}
}
@ -1621,8 +1660,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
return false;
}
return typeof schemaNode.constComparableValue !== "string" ||
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue;
if (typeof schemaNode.constComparableValue === "string" &&
buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) {
return false;
}
return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false);
}
if (schemaNode.type === "array") {
@ -1641,7 +1684,7 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
}
for (const item of yamlNode.items) {
if (!matchesSchemaNodeInternal(schemaNode.items, item)) {
if (!matchesSchemaNodeInternal(schemaNode.items, item, allowUnknownObjectProperties)) {
return false;
}
}
@ -1661,7 +1704,7 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
if (schemaNode.contains) {
let matchingContainsCount = 0;
for (const item of yamlNode.items) {
if (matchesSchemaNodeInternal(schemaNode.contains, item)) {
if (matchesSchemaNodeInternal(schemaNode.contains, item, true)) {
matchingContainsCount += 1;
}
}
@ -1679,8 +1722,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
}
}
return typeof schemaNode.constComparableValue !== "string" ||
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue;
if (typeof schemaNode.constComparableValue === "string" &&
buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) {
return false;
}
return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false);
}
if (!yamlNode || yamlNode.kind !== "scalar") {
@ -1753,8 +1800,36 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
return false;
}
return typeof schemaNode.constComparableValue !== "string" ||
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue;
if (typeof schemaNode.constComparableValue === "string" &&
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}`;
case ValidationMessageKeys.multipleOfViolation:
return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`;
case ValidationMessageKeys.notViolation:
return `属性“${params.displayPath}”不能匹配被 \`not\` 禁止的 schema。`;
case ValidationMessageKeys.minContainsViolation:
return `属性“${params.displayPath}”至少需要包含 ${params.value} 个匹配 contains 条件的元素。`;
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}.`;
case ValidationMessageKeys.multipleOfViolation:
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:
return `Property '${params.displayPath}' must contain at least ${params.value} items matching the 'contains' schema.`;
case ValidationMessageKeys.minItemsViolation:
@ -2727,7 +2806,8 @@ module.exports = {
* defaultValue?: string,
* constValue?: string,
* constDisplayValue?: string,
* constComparableValue?: string
* constComparableValue?: string,
* not?: SchemaNode
* } | {
* type: "array",
* displayPath: string,
@ -2744,6 +2824,7 @@ module.exports = {
* uniqueItems?: boolean,
* refTable?: string,
* contains?: SchemaNode,
* not?: SchemaNode,
* items: SchemaNode
* } | {
* type: "string" | "integer" | "number" | "boolean",
@ -2765,7 +2846,8 @@ module.exports = {
* patternRegex?: RegExp,
* format?: string,
* enumValues?: string[],
* refTable?: string
* refTable?: string,
* not?: SchemaNode
* }} SchemaNode
*/

View File

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

View File

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

View File

@ -1624,6 +1624,43 @@ test("parseSchemaContent should capture object property-count metadata", () => {
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", () => {
assert.throws(
() => parseSchemaContent(`
@ -1759,6 +1796,66 @@ reward: 1
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", () => {
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}),
"属性“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。");
});