docs(config): 添加配置系统文档和分析器规则定义

- 创建 AnalyzerReleases.Unshipped.md 定义41条新规则
- 添加游戏内容配置系统详细文档,涵盖YAML配置、JSON Schema结构、目录组织等
- 实现运行时只读查询功能,支持多种数据验证约束
- 集成Source Generator生成配置类型、表包装和访问辅助
- 提供VS Code插件支持配置浏览、编辑和校验功能
- 实现热重载机制支持开发期动态配置更新
- 添加Godot引擎文本配置桥接支持
- 提供Architecture架构接入模板和运行时读取接口
This commit is contained in:
GeWuYou 2026-04-16 18:25:48 +08:00
parent 594dffdd50
commit ed5d11576d
10 changed files with 1174 additions and 17 deletions

View File

@ -0,0 +1,360 @@
using System.IO;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证 YAML 配置加载器对对象级 <c>dependentRequired</c> 约束的运行时行为。
/// </summary>
[TestFixture]
public sealed class YamlConfigLoaderDependentRequiredTests
{
private string _rootPath = null!;
/// <summary>
/// 为每个用例创建隔离的临时目录,避免不同 dependentRequired 场景互相污染。
/// </summary>
[SetUp]
public void SetUp()
{
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_rootPath);
}
/// <summary>
/// 清理当前测试创建的目录,避免本地临时文件堆积。
/// </summary>
[TearDown]
public void TearDown()
{
if (Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, true);
}
}
/// <summary>
/// 验证触发字段出现但依赖字段缺失时,运行时会拒绝当前对象。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_DependentRequired_Property_Is_Missing()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
reward:
itemId: potion
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" },
"bonusId": { "type": "string" },
"bonusCount": { "type": "integer" }
},
"dependentRequired": {
"itemId": ["itemCount"],
"bonusId": ["bonusCount"]
}
}
}
}
""");
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
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.MissingRequiredProperty));
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward.itemCount"));
Assert.That(exception.Message, Does.Contain("required when sibling property 'reward.itemId' is present"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证触发字段未出现时,不会误报 dependentRequired 缺失。
/// </summary>
[Test]
public async Task LoadAsync_Should_Accept_When_Trigger_Property_Is_Absent()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
reward: {}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"dependentRequired": {
"itemId": ["itemCount"]
}
}
}
}
""");
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterRewardConfigStub>("monster");
Assert.That(table.Count, Is.EqualTo(1));
}
/// <summary>
/// 验证依赖字段同时存在时,当前对象可以正常通过加载。
/// </summary>
[Test]
public async Task LoadAsync_Should_Accept_When_DependentRequired_Properties_Are_Present()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
reward:
itemId: potion
itemCount: 3
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"dependentRequired": {
"itemId": ["itemCount"]
}
}
}
}
""");
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterRewardConfigStub>("monster");
var reward = table.Get(1).Reward;
Assert.Multiple(() =>
{
Assert.That(table.Count, Is.EqualTo(1));
Assert.That(reward.ItemId, Is.EqualTo("potion"));
Assert.That(reward.ItemCount, Is.EqualTo(3));
});
}
/// <summary>
/// 验证非对象 dependentRequired 声明会在 schema 解析阶段被拒绝。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_DependentRequired_Is_Not_An_Object()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
reward:
itemId: potion
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"dependentRequired": ["itemId"]
}
}
}
""");
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
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("reward"));
Assert.That(exception.Message, Does.Contain("must declare 'dependentRequired' as an object"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证 dependentRequired 只能引用同一对象内已声明的字段。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_DependentRequired_Target_Is_Not_Declared()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
reward:
itemId: potion
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" }
},
"dependentRequired": {
"itemId": ["itemCount"]
}
}
}
}
""");
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
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("reward"));
Assert.That(exception.Message, Does.Contain("dependentRequired"));
Assert.That(exception.Message, Does.Contain("itemCount"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 在测试目录下写入配置文件,并自动创建缺失目录。
/// </summary>
/// <param name="relativePath">相对根目录的配置文件路径。</param>
/// <param name="content">要写入的 YAML 或 schema 内容。</param>
private void CreateConfigFile(string relativePath, string content)
{
var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
var directoryPath = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
File.WriteAllText(filePath, content);
}
/// <summary>
/// 写入测试 schema 文件,复用统一的测试文件创建逻辑。
/// </summary>
/// <param name="relativePath">schema 相对路径。</param>
/// <param name="content">schema JSON 内容。</param>
private void CreateSchemaFile(string relativePath, string content)
{
CreateConfigFile(relativePath, content);
}
/// <summary>
/// 创建用于对象 dependentRequired 场景的加载器。
/// </summary>
/// <returns>已注册测试表与 schema 路径的加载器。</returns>
private YamlConfigLoader CreateMonsterRewardLoader()
{
return new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterRewardConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
}
/// <summary>
/// 创建新的配置注册表,确保每个用例从干净状态开始。
/// </summary>
/// <returns>空的配置注册表。</returns>
private static ConfigRegistry CreateRegistry()
{
return new ConfigRegistry();
}
/// <summary>
/// 用于对象 dependentRequired 回归测试的最小配置类型。
/// </summary>
private sealed class MonsterRewardConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置奖励对象。
/// </summary>
public RewardConfigStub Reward { get; set; } = new();
}
/// <summary>
/// 表示对象 dependentRequired 回归测试中的奖励节点。
/// </summary>
private sealed class RewardConfigStub
{
/// <summary>
/// 获取或设置掉落物 ID。
/// </summary>
public string ItemId { get; set; } = string.Empty;
/// <summary>
/// 获取或设置掉落物数量。
/// </summary>
public int ItemCount { get; set; }
}
}

View File

@ -10,8 +10,9 @@ namespace GFramework.Game.Config;
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 /// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
/// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、 /// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、 /// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c>、<c>maxProperties</c> 与稳定字符串 <c>format</c> 子集, /// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>
/// 让数值步进、数组去重、数组匹配计数和对象属性数量规则在运行时与生成器 / 工具侧保持一致。 /// 与稳定字符串 <c>format</c> 子集,让数值步进、数组去重、数组匹配计数、
/// 对象属性数量与对象内字段依赖规则在运行时与生成器 / 工具侧保持一致。
/// </summary> /// </summary>
internal static class YamlConfigSchemaValidator internal static class YamlConfigSchemaValidator
{ {
@ -460,7 +461,7 @@ internal static class YamlConfigSchemaValidator
var objectNode = YamlConfigSchemaNode.CreateObject( var objectNode = YamlConfigSchemaNode.CreateObject(
properties, properties,
requiredProperties, requiredProperties,
ParseObjectConstraints(tableName, schemaPath, propertyPath, element), ParseObjectConstraints(tableName, schemaPath, propertyPath, element, properties),
schemaPath); schemaPath);
objectNode = objectNode.WithAllowedValues( objectNode = objectNode.WithAllowedValues(
ParseEnumValues(tableName, schemaPath, propertyPath, element, objectNode, "enum")); ParseEnumValues(tableName, schemaPath, propertyPath, element, objectNode, "enum"));
@ -796,10 +797,7 @@ internal static class YamlConfigSchemaValidator
displayPath: requiredPath); displayPath: requiredPath);
} }
if (schemaNode.ObjectConstraints is not null) ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties, schemaNode);
{
ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties.Count, schemaNode);
}
ValidateAllowedValues(tableName, yamlPath, displayPath, mappingNode, schemaNode); ValidateAllowedValues(tableName, yamlPath, displayPath, mappingNode, schemaNode);
ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode); ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode);
@ -812,13 +810,13 @@ internal static class YamlConfigSchemaValidator
/// <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="propertyCount">当前对象实际属性数量。</param> /// <param name="seenProperties">当前对象已出现的属性集合。</param>
/// <param name="schemaNode">对象 schema 节点。</param> /// <param name="schemaNode">对象 schema 节点。</param>
private static void ValidateObjectConstraints( private static void ValidateObjectConstraints(
string tableName, string tableName,
string yamlPath, string yamlPath,
string displayPath, string displayPath,
int propertyCount, HashSet<string> seenProperties,
YamlConfigSchemaNode schemaNode) YamlConfigSchemaNode schemaNode)
{ {
var constraints = schemaNode.ObjectConstraints; var constraints = schemaNode.ObjectConstraints;
@ -827,6 +825,7 @@ internal static class YamlConfigSchemaValidator
return; return;
} }
var propertyCount = seenProperties.Count;
var subject = string.IsNullOrWhiteSpace(displayPath) var subject = string.IsNullOrWhiteSpace(displayPath)
? "Root object" ? "Root object"
: $"Property '{displayPath}'"; : $"Property '{displayPath}'";
@ -861,6 +860,42 @@ internal static class YamlConfigSchemaValidator
detail: detail:
$"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}."); $"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
} }
if (constraints.DependentRequired is null ||
constraints.DependentRequired.Count == 0)
{
return;
}
// Reuse the collected sibling-name set so the main validation path and
// the contains/not matcher both interpret object dependencies identically.
foreach (var dependency in constraints.DependentRequired)
{
if (!seenProperties.Contains(dependency.Key))
{
continue;
}
var triggerPath = CombineDisplayPath(displayPath, dependency.Key);
foreach (var dependentProperty in dependency.Value)
{
if (seenProperties.Contains(dependentProperty))
{
continue;
}
var requiredPath = CombineDisplayPath(displayPath, dependentProperty);
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.MissingRequiredProperty,
tableName,
$"Property '{requiredPath}' in config file '{yamlPath}' is required when sibling property '{triggerPath}' is present.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: requiredPath,
detail:
$"Dependent requirement: when '{triggerPath}' exists, '{requiredPath}' must also be declared.");
}
}
} }
/// <summary> /// <summary>
@ -1505,12 +1540,14 @@ internal static class YamlConfigSchemaValidator
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">对象字段路径。</param> /// <param name="propertyPath">对象字段路径。</param>
/// <param name="element">Schema 节点。</param> /// <param name="element">Schema 节点。</param>
/// <param name="properties">当前对象已声明的属性集合。</param>
/// <returns>对象约束模型;未声明时返回空。</returns> /// <returns>对象约束模型;未声明时返回空。</returns>
private static YamlConfigObjectConstraints? ParseObjectConstraints( private static YamlConfigObjectConstraints? ParseObjectConstraints(
string tableName, string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
JsonElement element) JsonElement element,
IReadOnlyDictionary<string, YamlConfigSchemaNode> properties)
{ {
var minProperties = TryParseObjectPropertyCountConstraint( var minProperties = TryParseObjectPropertyCountConstraint(
tableName, tableName,
@ -1524,6 +1561,7 @@ internal static class YamlConfigSchemaValidator
propertyPath, propertyPath,
element, element,
"maxProperties"); "maxProperties");
var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties);
if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value)
{ {
@ -1536,9 +1574,118 @@ internal static class YamlConfigSchemaValidator
displayPath: GetDiagnosticPath(propertyPath)); displayPath: GetDiagnosticPath(propertyPath));
} }
return !minProperties.HasValue && !maxProperties.HasValue return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null
? null ? null
: new YamlConfigObjectConstraints(minProperties, maxProperties); : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired);
}
/// <summary>
/// 解析对象节点声明的 <c>dependentRequired</c> 依赖关系。
/// 该关键字只表达“当触发字段出现时,还必须同时声明哪些同级字段”,
/// 因此这里会把触发字段与依赖字段都限制在当前对象已声明的属性集合内,
/// 避免运行时与工具链对无效键名各自做隐式容错。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">对象字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="properties">当前对象已声明的属性集合。</param>
/// <returns>归一化后的依赖关系表;未声明或只有空依赖时返回空。</returns>
private static IReadOnlyDictionary<string, IReadOnlyList<string>>? ParseDependentRequiredConstraints(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
IReadOnlyDictionary<string, YamlConfigSchemaNode> properties)
{
if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement))
{
return null;
}
if (dependentRequiredElement.ValueKind != JsonValueKind.Object)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentRequired' as an object.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var dependentRequired = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
foreach (var dependency in dependentRequiredElement.EnumerateObject())
{
if (!properties.ContainsKey(dependency.Name))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' for undeclared property '{dependency.Name}'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (dependency.Value.ValueKind != JsonValueKind.Array)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var dependencyTargets = new List<string>();
var seenDependencyTargets = new HashSet<string>(StringComparer.Ordinal);
foreach (var dependencyTarget in dependency.Value.EnumerateArray())
{
if (dependencyTarget.ValueKind != JsonValueKind.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var dependencyTargetName = dependencyTarget.GetString();
if (string.IsNullOrWhiteSpace(dependencyTargetName))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{dependency.Name}' in {DescribeObjectSchemaTarget(propertyPath).ToLowerInvariant()} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (!properties.ContainsKey(dependencyTargetName))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' target '{dependencyTargetName}' that is not declared in the same object schema.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (seenDependencyTargets.Add(dependencyTargetName))
{
dependencyTargets.Add(dependencyTargetName);
}
}
if (dependencyTargets.Count > 0)
{
dependentRequired[dependency.Name] = dependencyTargets;
}
}
return dependentRequired.Count == 0
? null
: dependentRequired;
} }
/// <summary> /// <summary>
@ -3868,7 +4015,7 @@ internal sealed class YamlConfigAllowedValue
} }
/// <summary> /// <summary>
/// 表示一个对象节点上声明的属性数量约束 /// 表示一个对象节点上声明的属性数量约束与字段依赖约束
/// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。 /// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。
/// </summary> /// </summary>
internal sealed class YamlConfigObjectConstraints internal sealed class YamlConfigObjectConstraints
@ -3878,10 +4025,15 @@ internal sealed class YamlConfigObjectConstraints
/// </summary> /// </summary>
/// <param name="minProperties">最小属性数量约束。</param> /// <param name="minProperties">最小属性数量约束。</param>
/// <param name="maxProperties">最大属性数量约束。</param> /// <param name="maxProperties">最大属性数量约束。</param>
public YamlConfigObjectConstraints(int? minProperties, int? maxProperties) /// <param name="dependentRequired">对象内字段依赖约束。</param>
public YamlConfigObjectConstraints(
int? minProperties,
int? maxProperties,
IReadOnlyDictionary<string, IReadOnlyList<string>>? dependentRequired)
{ {
MinProperties = minProperties; MinProperties = minProperties;
MaxProperties = maxProperties; MaxProperties = maxProperties;
DependentRequired = dependentRequired;
} }
/// <summary> /// <summary>
@ -3893,6 +4045,12 @@ internal sealed class YamlConfigObjectConstraints
/// 获取最大属性数量约束。 /// 获取最大属性数量约束。
/// </summary> /// </summary>
public int? MaxProperties { get; } public int? MaxProperties { get; }
/// <summary>
/// 获取对象内字段依赖约束。
/// 键表示“触发字段”,值表示“触发字段出现后还必须存在的同级字段集合”。
/// </summary>
public IReadOnlyDictionary<string, IReadOnlyList<string>>? DependentRequired { get; }
} }
/// <summary> /// <summary>

View File

@ -368,6 +368,111 @@ public class SchemaConfigGeneratorTests
}); });
} }
/// <summary>
/// 验证对象 <c>dependentRequired</c> 会写入生成 XML 文档。
/// </summary>
[Test]
public void Run_Should_Write_DependentRequired_Constraint_Into_Generated_Documentation()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" },
"bonusId": { "type": "string" },
"bonusCount": { "type": "integer" }
},
"dependentRequired": {
"itemId": ["itemCount"],
"bonusId": ["bonusCount"]
}
}
}
}
""";
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: dependentRequired = { itemId =&gt; [itemCount]; bonusId =&gt; [bonusCount] }."));
}
/// <summary>
/// 验证生成器会拒绝引用未声明对象字段的 <c>dependentRequired</c> 元数据。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_DependentRequired_Target_Is_Not_Declared()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" }
},
"dependentRequired": {
"itemId": ["itemCount"]
}
}
}
}
""";
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_010"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("reward"));
Assert.That(diagnostic.GetMessage(), Does.Contain("itemCount"));
});
}
/// <summary> /// <summary>
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
/// </summary> /// </summary>

View File

@ -27,6 +27,7 @@
GF_ConfigSchema_007 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_007 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_AutoModule_001 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics GF_AutoModule_001 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
GF_AutoModule_002 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics GF_AutoModule_002 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
GF_AutoModule_003 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics GF_AutoModule_003 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics

View File

@ -8,7 +8,8 @@ namespace GFramework.SourceGenerators.Config;
/// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。 /// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。
/// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、 /// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、 /// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c>、<c>maxProperties</c> 与稳定字符串 <c>format</c> 子集写入生成代码文档, /// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>
/// 与稳定字符串 <c>format</c> 子集写入生成代码文档,
/// 让消费者能直接在强类型 API 上看到运行时生效的约束。 /// 让消费者能直接在强类型 API 上看到运行时生效的约束。
/// </summary> /// </summary>
[Generator] [Generator]
@ -140,6 +141,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return SchemaParseResult.FromDiagnostic(rootFormatDiagnostic!); return SchemaParseResult.FromDiagnostic(rootFormatDiagnostic!);
} }
if (!TryValidateDependentRequiredMetadataRecursively(
file.Path,
"<root>",
root,
out var dependentRequiredDiagnostic))
{
return SchemaParseResult.FromDiagnostic(dependentRequiredDiagnostic!);
}
var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); var entityName = ToPascalCase(GetSchemaBaseName(file.Path));
var rootObject = ParseObjectSpec( var rootObject = ParseObjectSpec(
file.Path, file.Path,
@ -707,6 +717,210 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return true; return true;
} }
/// <summary>
/// 递归验证 schema 树中的对象级 <c>dependentRequired</c> 元数据。
/// 该遍历会覆盖根节点、<c>not</c> 子 schema、数组元素与 <c>contains</c> 子 schema
/// 避免生成器在对象字段依赖规则上比运行时和工具侧更宽松。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点树的 dependentRequired 元数据是否有效。</returns>
private static bool TryValidateDependentRequiredMetadataRecursively(
string filePath,
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
{
diagnostic = null;
if (element.ValueKind != JsonValueKind.Object)
{
return true;
}
var schemaType = string.Empty;
if (element.TryGetProperty("type", out var typeElement) &&
typeElement.ValueKind == JsonValueKind.String)
{
schemaType = typeElement.GetString() ?? string.Empty;
if (string.Equals(schemaType, "object", StringComparison.Ordinal) &&
!TryValidateDependentRequiredMetadata(filePath, displayPath, element, out diagnostic))
{
return false;
}
}
if (string.Equals(schemaType, "object", StringComparison.Ordinal) &&
element.TryGetProperty("properties", out var propertiesElement) &&
propertiesElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in propertiesElement.EnumerateObject())
{
if (!TryValidateDependentRequiredMetadataRecursively(
filePath,
CombinePath(displayPath, property.Name),
property.Value,
out diagnostic))
{
return false;
}
}
}
if (element.TryGetProperty("not", out var notElement) &&
notElement.ValueKind == JsonValueKind.Object &&
!TryValidateDependentRequiredMetadataRecursively(
filePath,
$"{displayPath}[not]",
notElement,
out diagnostic))
{
return false;
}
if (!string.Equals(schemaType, "array", StringComparison.Ordinal))
{
return true;
}
if (element.TryGetProperty("items", out var itemsElement) &&
itemsElement.ValueKind == JsonValueKind.Object &&
!TryValidateDependentRequiredMetadataRecursively(filePath, $"{displayPath}[]", itemsElement, out diagnostic))
{
return false;
}
if (element.TryGetProperty("contains", out var containsElement) &&
containsElement.ValueKind == JsonValueKind.Object &&
!TryValidateDependentRequiredMetadataRecursively(
filePath,
$"{displayPath}[contains]",
containsElement,
out diagnostic))
{
return false;
}
return true;
}
/// <summary>
/// 验证单个对象 schema 节点上的 <c>dependentRequired</c> 元数据。
/// 生成器只接受“当前对象已声明字段之间”的依赖关系,避免强类型文档描述出运行时无法解析的无效键名。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前对象 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前对象上的 dependentRequired 元数据是否有效。</returns>
private static bool TryValidateDependentRequiredMetadata(
string filePath,
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
{
diagnostic = null;
if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement))
{
return true;
}
if (dependentRequiredElement.ValueKind != JsonValueKind.Object)
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
"The 'dependentRequired' value must be an object.");
return false;
}
if (!element.TryGetProperty("properties", out var propertiesElement) ||
propertiesElement.ValueKind != JsonValueKind.Object)
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
"Object schemas using 'dependentRequired' must also declare an object-valued 'properties' map.");
return false;
}
var declaredProperties = new HashSet<string>(
propertiesElement
.EnumerateObject()
.Select(static property => property.Name),
StringComparer.Ordinal);
foreach (var dependency in dependentRequiredElement.EnumerateObject())
{
if (!declaredProperties.Contains(dependency.Name))
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
$"Trigger property '{dependency.Name}' is not declared in the same object schema.");
return false;
}
if (dependency.Value.ValueKind != JsonValueKind.Array)
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
$"Property '{dependency.Name}' must declare 'dependentRequired' as an array of sibling property names.");
return false;
}
foreach (var dependencyTarget in dependency.Value.EnumerateArray())
{
if (dependencyTarget.ValueKind != JsonValueKind.String)
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
$"Property '{dependency.Name}' must declare 'dependentRequired' entries as strings.");
return false;
}
var dependencyTargetName = dependencyTarget.GetString();
if (string.IsNullOrWhiteSpace(dependencyTargetName))
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
$"Property '{dependency.Name}' cannot declare blank 'dependentRequired' entries.");
return false;
}
var normalizedDependencyTargetName = dependencyTargetName!;
if (!declaredProperties.Contains(normalizedDependencyTargetName))
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidDependentRequiredMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
$"Dependent target '{normalizedDependencyTargetName}' is not declared in the same object schema.");
return false;
}
}
}
return true;
}
/// <summary> /// <summary>
/// 判断给定 format 名称是否属于当前共享支持子集。 /// 判断给定 format 名称是否属于当前共享支持子集。
/// </summary> /// </summary>
@ -2933,9 +3147,58 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
parts.Add($"maxProperties = {maxProperties.ToString(CultureInfo.InvariantCulture)}"); parts.Add($"maxProperties = {maxProperties.ToString(CultureInfo.InvariantCulture)}");
} }
if (schemaType == "object")
{
var dependentRequiredDocumentation = TryBuildDependentRequiredDocumentation(element);
if (dependentRequiredDocumentation is not null)
{
parts.Add($"dependentRequired = {dependentRequiredDocumentation}");
}
}
return parts.Count > 0 ? string.Join(", ", parts) : null; return parts.Count > 0 ? string.Join(", ", parts) : null;
} }
/// <summary>
/// 将对象 <c>dependentRequired</c> 依赖关系整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">对象 schema 节点。</param>
/// <returns>格式化后的 dependentRequired 说明。</returns>
private static string? TryBuildDependentRequiredDocumentation(JsonElement element)
{
if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement) ||
dependentRequiredElement.ValueKind != JsonValueKind.Object)
{
return null;
}
var parts = new List<string>();
foreach (var dependency in dependentRequiredElement.EnumerateObject())
{
if (dependency.Value.ValueKind != JsonValueKind.Array)
{
continue;
}
var targets = dependency.Value
.EnumerateArray()
.Where(static item => item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString()))
.Select(static item => item.GetString()!)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (targets.Length == 0)
{
continue;
}
parts.Add($"{dependency.Name} => [{string.Join(", ", targets)}]");
}
return parts.Count > 0
? $"{{ {string.Join("; ", parts)} }}"
: null;
}
/// <summary> /// <summary>
/// 将数组 <c>contains</c> 子 schema 整理成 XML 文档可读字符串。 /// 将数组 <c>contains</c> 子 schema 整理成 XML 文档可读字符串。
/// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。 /// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。

View File

@ -12,7 +12,7 @@
- JSON Schema 作为结构描述 - JSON Schema 作为结构描述
- 一对象一文件的目录组织 - 一对象一文件的目录组织
- 运行时只读查询 - 运行时只读查询
- Runtime / Generator / Tooling 共享支持 `enum``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` - Runtime / Generator / Tooling 共享支持 `enum``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``dependentRequired`
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
@ -719,6 +719,7 @@ var loader = new YamlConfigLoader("config-root")
- 数组字段违反 `uniqueItems` - 数组字段违反 `uniqueItems`
- 数组字段违反 `contains` / `minContains` / `maxContains` - 数组字段违反 `contains` / `minContains` / `maxContains`
- 对象字段违反 `minProperties` / `maxProperties` - 对象字段违反 `minProperties` / `maxProperties`
- 对象字段违反 `dependentRequired`
- 标量 / 对象 / 数组字段违反 `const` - 标量 / 对象 / 数组字段违反 `const`
- 标量 / 对象 / 数组字段命中 `not` - 标量 / 对象 / 数组字段命中 `not`
- 标量 / 对象 / 数组字段违反 `enum` - 标量 / 对象 / 数组字段违反 `enum`
@ -783,6 +784,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `uniqueItems`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 - `uniqueItems`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序
- `contains` / `minContains` / `maxContains`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前会按同一套递归 schema 规则统计“有多少数组元素匹配 contains 子 schema”其中仅声明 `contains` 时默认至少需要 1 个匹配元素 - `contains` / `minContains` / `maxContains`供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前会按同一套递归 schema 规则统计“有多少数组元素匹配 contains 子 schema”其中仅声明 `contains` 时默认至少需要 1 个匹配元素
- `minProperties` / `maxProperties`供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束 - `minProperties` / `maxProperties`供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束
- `dependentRequired`供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只表达“当对象内某个字段出现时,还必须同时声明哪些同级字段”,不会改变生成类型形状
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
@ -879,7 +881,7 @@ var hotReload = loader.EnableHotReload(
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新 - 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-tableUI 中显示为 ref-table / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 - 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-tableUI 中显示为 ref-table / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。

View File

@ -1133,6 +1133,7 @@ function parseSchemaNode(rawNode, displayPath) {
for (const [key, propertyNode] of Object.entries(value.properties || {})) { for (const [key, propertyNode] of Object.entries(value.properties || {})) {
properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key)); properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key));
} }
const dependentRequired = parseDependentRequiredMetadata(value.dependentRequired, displayPath, properties);
return applyEnumMetadata(applyConstMetadata({ return applyEnumMetadata(applyConstMetadata({
type: "object", type: "object",
@ -1141,6 +1142,7 @@ function parseSchemaNode(rawNode, displayPath) {
properties, properties,
minProperties: metadata.minProperties, minProperties: metadata.minProperties,
maxProperties: metadata.maxProperties, maxProperties: metadata.maxProperties,
dependentRequired,
title: metadata.title, title: metadata.title,
description: metadata.description, description: metadata.description,
defaultValue: metadata.defaultValue, defaultValue: metadata.defaultValue,
@ -1254,6 +1256,72 @@ function parseNegatedSchemaNode(rawNot, displayPath) {
return parseSchemaNode(rawNot, `${displayPath}[not]`); return parseSchemaNode(rawNot, `${displayPath}[not]`);
} }
/**
* Parse one object-level `dependentRequired` map and keep it aligned with the
* runtime's "declared siblings only" contract.
*
* @param {unknown} rawDependentRequired Raw dependentRequired node.
* @param {string} displayPath Parent schema path.
* @param {Record<string, SchemaNode>} properties Declared object properties.
* @returns {Record<string, string[]> | undefined} Normalized dependency map.
*/
function parseDependentRequiredMetadata(rawDependentRequired, displayPath, properties) {
if (rawDependentRequired === undefined) {
return undefined;
}
if (!rawDependentRequired ||
typeof rawDependentRequired !== "object" ||
Array.isArray(rawDependentRequired)) {
throw new Error(`Schema property '${displayPath}' must declare 'dependentRequired' as an object.`);
}
const normalized = {};
for (const [triggerProperty, rawDependencies] of Object.entries(rawDependentRequired)) {
if (!Object.prototype.hasOwnProperty.call(properties, triggerProperty)) {
throw new Error(
`Schema property '${displayPath}' declares 'dependentRequired' for undeclared property '${triggerProperty}'.`);
}
if (!Array.isArray(rawDependencies)) {
throw new Error(
`Schema property '${displayPath}' must declare 'dependentRequired' for '${triggerProperty}' as an array of sibling property names.`);
}
const dependencies = [];
const seenDependencies = new Set();
for (const dependency of rawDependencies) {
if (typeof dependency !== "string") {
throw new Error(
`Schema property '${displayPath}' must declare 'dependentRequired' entries for '${triggerProperty}' as strings.`);
}
if (dependency.trim().length === 0) {
throw new Error(
`Schema property '${displayPath}' cannot declare blank 'dependentRequired' entries for '${triggerProperty}'.`);
}
if (!Object.prototype.hasOwnProperty.call(properties, dependency)) {
throw new Error(
`Schema property '${displayPath}' declares 'dependentRequired' target '${dependency}' that is not declared in the same object schema.`);
}
if (!seenDependencies.has(dependency)) {
seenDependencies.add(dependency);
dependencies.push(dependency);
}
}
if (dependencies.length > 0) {
normalized[triggerProperty] = dependencies;
}
}
return Object.keys(normalized).length > 0
? normalized
: undefined;
}
/** /**
* Validate one schema node against one YAML node. * Validate one schema node against one YAML node.
* *
@ -1565,6 +1633,11 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
} }
} }
const reportedMessages = new Set(
diagnostics
.slice(diagnosticsBeforeNode)
.map((diagnostic) => diagnostic.message));
for (const entry of yamlNode.entries) { for (const entry of yamlNode.entries) {
if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) {
diagnostics.push({ diagnostics.push({
@ -1584,6 +1657,38 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
localizer); localizer);
} }
if (schemaNode.dependentRequired && typeof schemaNode.dependentRequired === "object") {
for (const [triggerProperty, dependencies] of Object.entries(schemaNode.dependentRequired)) {
if (!yamlNode.map.has(triggerProperty)) {
continue;
}
for (const dependency of dependencies) {
if (yamlNode.map.has(dependency)) {
continue;
}
const localizedMessage = localizeValidationMessage(
ValidationMessageKeys.dependentRequiredViolation,
localizer,
{
displayPath: joinPropertyPath(displayPath, dependency),
triggerProperty: joinPropertyPath(displayPath, triggerProperty)
});
if (reportedMessages.has(localizedMessage)) {
continue;
}
diagnostics.push({
severity: "error",
message: localizedMessage
});
reportedMessages.add(localizedMessage);
}
}
}
if (typeof schemaNode.minProperties === "number" && if (typeof schemaNode.minProperties === "number" &&
propertyCount < schemaNode.minProperties) { propertyCount < schemaNode.minProperties) {
diagnostics.push({ diagnostics.push({
@ -1671,6 +1776,20 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope
} }
} }
if (schemaNode.dependentRequired && typeof schemaNode.dependentRequired === "object") {
for (const [triggerProperty, dependencies] of Object.entries(schemaNode.dependentRequired)) {
if (!yamlNode.map.has(triggerProperty)) {
continue;
}
for (const dependency of dependencies) {
if (!yamlNode.map.has(dependency)) {
return false;
}
}
}
}
if (typeof schemaNode.minProperties === "number" && if (typeof schemaNode.minProperties === "number" &&
propertyCount < schemaNode.minProperties) { propertyCount < schemaNode.minProperties) {
return false; return false;
@ -2082,6 +2201,8 @@ function localizeValidationMessage(key, localizer, params) {
switch (key) { switch (key) {
case ValidationMessageKeys.constMismatch: case ValidationMessageKeys.constMismatch:
return `属性“${params.displayPath}”必须匹配固定值 ${params.value}`; return `属性“${params.displayPath}”必须匹配固定值 ${params.value}`;
case ValidationMessageKeys.dependentRequiredViolation:
return `属性“${params.triggerProperty}”存在时,必须同时声明属性“${params.displayPath}”。`;
case ValidationMessageKeys.expectedArray: case ValidationMessageKeys.expectedArray:
return `属性“${params.displayPath}”应为数组。`; return `属性“${params.displayPath}”应为数组。`;
case ValidationMessageKeys.expectedScalarShape: case ValidationMessageKeys.expectedScalarShape:
@ -2132,6 +2253,8 @@ function localizeValidationMessage(key, localizer, params) {
switch (key) { switch (key) {
case ValidationMessageKeys.constMismatch: case ValidationMessageKeys.constMismatch:
return `Property '${params.displayPath}' must match constant value ${params.value}.`; return `Property '${params.displayPath}' must match constant value ${params.value}.`;
case ValidationMessageKeys.dependentRequiredViolation:
return `Property '${params.displayPath}' is required when sibling property '${params.triggerProperty}' is present.`;
case ValidationMessageKeys.expectedArray: case ValidationMessageKeys.expectedArray:
return `Property '${params.displayPath}' is expected to be an array.`; return `Property '${params.displayPath}' is expected to be an array.`;
case ValidationMessageKeys.expectedScalarShape: case ValidationMessageKeys.expectedScalarShape:
@ -2880,6 +3003,7 @@ module.exports = {
* properties: Record<string, SchemaNode>, * properties: Record<string, SchemaNode>,
* minProperties?: number, * minProperties?: number,
* maxProperties?: number, * maxProperties?: number,
* dependentRequired?: Record<string, string[]>,
* title?: string, * title?: string,
* description?: string, * description?: string,
* defaultValue?: string, * defaultValue?: string,

View File

@ -133,6 +133,7 @@ const enMessages = {
"webview.hint.itemFormat": "Item format: {value}", "webview.hint.itemFormat": "Item format: {value}",
"webview.hint.minProperties": "Min properties: {value}", "webview.hint.minProperties": "Min properties: {value}",
"webview.hint.maxProperties": "Max properties: {value}", "webview.hint.maxProperties": "Max properties: {value}",
"webview.hint.dependentRequired": "When {trigger} is set: require {dependencies}",
"webview.hint.refTable": "Ref table: {refTable}", "webview.hint.refTable": "Ref table: {refTable}",
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.", "webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.", "webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
@ -253,6 +254,7 @@ const zhCnMessages = {
"webview.hint.itemFormat": "元素格式:{value}", "webview.hint.itemFormat": "元素格式:{value}",
"webview.hint.minProperties": "最少属性数:{value}", "webview.hint.minProperties": "最少属性数:{value}",
"webview.hint.maxProperties": "最多属性数:{value}", "webview.hint.maxProperties": "最多属性数:{value}",
"webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}",
"webview.hint.refTable": "引用表:{refTable}", "webview.hint.refTable": "引用表:{refTable}",
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",

View File

@ -1624,6 +1624,73 @@ 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 dependentRequired metadata", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"dependentRequired": {
"itemId": ["itemCount"]
}
}
}
}
`);
assert.deepEqual(schema.properties.reward.dependentRequired, {
itemId: ["itemCount"]
});
});
test("parseSchemaContent should reject non-object dependentRequired declarations", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"dependentRequired": ["itemId"]
}
}
}
`),
/must declare 'dependentRequired' as an object/u
);
});
test("parseSchemaContent should reject dependentRequired targets outside the same object schema", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" }
},
"dependentRequired": {
"itemId": ["itemCount"]
}
}
}
}
`),
/dependentRequired' target 'itemCount'/u
);
});
test("parseSchemaContent should capture not sub-schema metadata", () => { test("parseSchemaContent should capture not sub-schema metadata", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {
@ -1644,6 +1711,63 @@ test("parseSchemaContent should capture not sub-schema metadata", () => {
assert.equal(schema.properties.name.not.constDisplayValue, "\"Deprecated\""); assert.equal(schema.properties.name.not.constDisplayValue, "\"Deprecated\"");
}); });
test("validateParsedConfig should report missing dependentRequired siblings", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"dependentRequired": {
"itemId": ["itemCount"]
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward:
itemId: potion
`);
assert.deepEqual(validateParsedConfig(schema, yaml), [
{
severity: "error",
message: "Property 'reward.itemCount' is required when sibling property 'reward.itemId' is present."
}
]);
});
test("validateParsedConfig should accept missing dependentRequired targets when the trigger is absent", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"itemCount": { "type": "integer" }
},
"dependentRequired": {
"itemId": ["itemCount"]
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward:
itemCount: 3
`);
assert.deepEqual(validateParsedConfig(schema, yaml), []);
});
test("parseSchemaContent should reject non-object not declarations", () => { test("parseSchemaContent should reject non-object not declarations", () => {
assert.throws( assert.throws(
() => parseSchemaContent(` () => parseSchemaContent(`

View File

@ -81,3 +81,21 @@ test("createLocalizer should expose not validation keys", () => {
chineseLocalizer.t(ValidationMessageKeys.notViolation, {displayPath: "name"}), chineseLocalizer.t(ValidationMessageKeys.notViolation, {displayPath: "name"}),
"属性“name”不能匹配被 `not` 禁止的 schema。"); "属性“name”不能匹配被 `not` 禁止的 schema。");
}); });
test("createLocalizer should expose dependentRequired validation keys", () => {
const englishLocalizer = createLocalizer("en");
const chineseLocalizer = createLocalizer("zh-cn");
assert.equal(
englishLocalizer.t(ValidationMessageKeys.dependentRequiredViolation, {
displayPath: "reward.itemCount",
triggerProperty: "reward.itemId"
}),
"Property 'reward.itemCount' is required when sibling property 'reward.itemId' is present.");
assert.equal(
chineseLocalizer.t(ValidationMessageKeys.dependentRequiredViolation, {
displayPath: "reward.itemCount",
triggerProperty: "reward.itemId"
}),
"属性“reward.itemId”存在时必须同时声明属性“reward.itemCount”。");
});