feat(config): 添加配置验证工具和代码分析规则

- 实现配置架构解析器和验证功能
- 添加YAML解析和注释提取功能
- 创建配置验证诊断规则表格
- 实现批量编辑器支持的字段提取
- 添加字符串格式验证(日期、邮箱、UUID等)
- 创建示例配置YAML生成功能
- 实现表单更新应用到YAML的功能
- 添加常量和枚举值的元数据处理
- 实现精确小数倍数验证算法
- 添加配置模式规范化和比较功能
This commit is contained in:
GeWuYou 2026-04-17 09:43:23 +08:00
parent 5185247c35
commit 01a815a518
8 changed files with 247 additions and 37 deletions

View File

@ -18,17 +18,6 @@
GF_ContextRegistration_001 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
GF_ContextRegistration_002 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
GF_ContextRegistration_003 | GFramework.SourceGenerators.rule | Warning | ContextRegistrationDiagnostics
GF_ConfigSchema_001 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_002 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_007 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_AutoModule_001 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
GF_AutoModule_002 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics
GF_AutoModule_003 | GFramework.SourceGenerators.Architecture | Error | AutoRegisterModuleDiagnostics

View File

@ -15,3 +15,4 @@
GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics

View File

@ -958,6 +958,44 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return true;
}
/// <summary>
/// 验证当前 schema 节点是否以运行时支持的方式声明了 <c>dependentSchemas</c>。
/// 只有 object 节点允许挂载该关键字;一旦关键字出现,就继续复用对象节点的形状校验,
/// 保证发布到 XML 文档和运行时的约束解释范围保持一致。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="schemaType">当前节点声明的 schema 类型。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点上的 dependentSchemas 声明是否有效。</returns>
private static bool TryValidateDependentSchemasDeclaration(
string filePath,
string displayPath,
JsonElement element,
string? schemaType,
out Diagnostic? diagnostic)
{
diagnostic = null;
if (!element.TryGetProperty("dependentSchemas", out _))
{
return true;
}
if (!string.Equals(schemaType, "object", StringComparison.Ordinal))
{
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidDependentSchemasMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
"Only object schemas can declare 'dependentSchemas'.");
return false;
}
return TryValidateDependentSchemasMetadata(filePath, displayPath, element, out diagnostic);
}
/// <summary>
/// 递归验证 schema 树中的对象级 <c>dependentSchemas</c> 元数据。
/// 该遍历会覆盖根节点、<c>not</c>、数组元素、<c>contains</c> 与嵌套 <c>dependentSchemas</c>
@ -980,15 +1018,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
element,
static (currentFilePath, currentDisplayPath, currentElement, schemaType) =>
{
if (!string.Equals(schemaType, "object", StringComparison.Ordinal))
{
return (true, (Diagnostic?)null);
}
return TryValidateDependentSchemasMetadata(
return TryValidateDependentSchemasDeclaration(
currentFilePath,
currentDisplayPath,
currentElement,
schemaType,
out var currentDiagnostic)
? (true, (Diagnostic?)null)
: (false, currentDiagnostic);
@ -3447,6 +3481,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains / not 与主属性文档逐渐漂移。
/// </summary>
/// <param name="schemaElement">内联子 schema。</param>
/// <param name="includeRequiredProperties">
/// 为对象摘要额外输出 <c>required</c> 信息时返回 <see langword="true" />。
/// </param>
/// <returns>格式化后的摘要字符串。</returns>
private static string? TryBuildInlineSchemaSummary(
JsonElement schemaElement,

View File

@ -10,7 +10,7 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public sealed class YamlConfigLoaderDependentSchemasTests
{
private string _rootPath = null!;
private string? _rootPath;
/// <summary>
/// 为每个用例创建隔离的临时目录,避免不同 dependentSchemas 场景互相污染。
@ -28,7 +28,8 @@ public sealed class YamlConfigLoaderDependentSchemasTests
[TearDown]
public void TearDown()
{
if (Directory.Exists(_rootPath))
if (!string.IsNullOrEmpty(_rootPath) &&
Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, true);
}
@ -310,6 +311,8 @@ public sealed class YamlConfigLoaderDependentSchemasTests
/// <param name="content">要写入的 YAML 或 schema 内容。</param>
private void CreateConfigFile(string relativePath, string content)
{
ArgumentNullException.ThrowIfNull(_rootPath);
var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
var directoryPath = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directoryPath))
@ -336,6 +339,8 @@ public sealed class YamlConfigLoaderDependentSchemasTests
/// <returns>已注册测试表与 schema 路径的加载器。</returns>
private YamlConfigLoader CreateMonsterRewardLoader()
{
ArgumentNullException.ThrowIfNull(_rootPath);
return new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterDependentSchemasConfigStub>(
"monster",

View File

@ -9,7 +9,7 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public sealed class YamlConfigSchemaValidatorTests
{
private string _rootPath = null!;
private string? _rootPath;
/// <summary>
/// 为每个测试准备独立临时目录。
@ -27,7 +27,8 @@ public sealed class YamlConfigSchemaValidatorTests
[TearDown]
public void TearDown()
{
if (Directory.Exists(_rootPath))
if (!string.IsNullOrEmpty(_rootPath) &&
Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, true);
}
@ -70,6 +71,61 @@ public sealed class YamlConfigSchemaValidatorTests
Assert.That(schema.ReferencedTableNames, Is.EqualTo(new[] { "ally", "item", "weapon" }));
}
/// <summary>
/// 验证条件子 schema 复用同一条 ref-table 字段时,不会把同一引用重复写入结果。
/// </summary>
[Test]
public void ValidateAndCollectReferences_Should_Not_Duplicate_Reference_Usages_From_DependentSchemas()
{
var schemaPath = CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
},
"dependentSchemas": {
"itemId": {
"type": "object",
"properties": {
"itemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
}
}
}
}
""");
var schema = YamlConfigSchemaValidator.Load("monster", schemaPath);
var references = YamlConfigSchemaValidator.ValidateAndCollectReferences(
"monster",
schema,
"monster/slime.yaml",
"""
reward:
itemId: potion
""");
Assert.That(references, Has.Count.EqualTo(1));
Assert.Multiple(() =>
{
Assert.That(references[0].DisplayPath, Is.EqualTo("reward.itemId"));
Assert.That(references[0].ReferencedTableName, Is.EqualTo("item"));
Assert.That(references[0].RawValue, Is.EqualTo("potion"));
});
}
/// <summary>
/// 在临时目录中创建 schema 文件。
/// </summary>
@ -80,6 +136,8 @@ public sealed class YamlConfigSchemaValidatorTests
string relativePath,
string content)
{
ArgumentNullException.ThrowIfNull(_rootPath);
var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
var directoryPath = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directoryPath))

View File

@ -813,15 +813,20 @@ internal static class YamlConfigSchemaValidator
}
/// <summary>
/// 校验对象节点声明的属性数量约束。
/// 校验对象节点声明的数量约束与条件对象约束。
/// 该阶段除了检查 <c>minProperties</c> / <c>maxProperties</c>,还会复用同一份 sibling 集合处理
/// <c>dependentRequired</c>,并在 <c>dependentSchemas</c> 命中时以 focused constraint block 语义
/// 对整个 <paramref name="mappingNode" /> 做额外试匹配。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">对象字段路径;根对象时为空。</param>
/// <param name="mappingNode">当前 YAML 对象节点。</param>
/// <param name="mappingNode">当前 YAML 对象节点;用于让条件子 schema 在完整对象视图上做匹配。</param>
/// <param name="seenProperties">当前对象已出现的属性集合。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <param name="references">可选的跨表引用收集器。</param>
/// <param name="references">
/// 可选的跨表引用收集器;当 <c>dependentSchemas</c> 命中且匹配成功时,只会回写该条件分支新增的引用。
/// </param>
private static void ValidateObjectConstraints(
string tableName,
string yamlPath,
@ -924,6 +929,9 @@ internal static class YamlConfigSchemaValidator
// dependentSchemas acts as an additional conditional constraint block on the
// current object. Keep undeclared sibling fields outside the dependent sub-schema
// from blocking the match so schema authors can express focused follow-up rules.
// The trial matcher merges only new reference usages back into the outer collector,
// so re-checking the same scalar via a conditional sub-schema does not duplicate
// cross-table validation work later in the loader pipeline.
if (TryMatchSchemaNode(
tableName,
yamlPath,
@ -3138,10 +3146,7 @@ internal static class YamlConfigSchemaValidator
if (references is not null &&
matchedReferences is not null)
{
foreach (var referenceUsage in matchedReferences)
{
references.Add(referenceUsage);
}
AddUniqueReferenceUsages(references, matchedReferences);
}
return true;
@ -3153,6 +3158,50 @@ internal static class YamlConfigSchemaValidator
}
}
/// <summary>
/// 将试匹配分支采集到的引用回写到外层集合,并按结构化标识去重。
/// </summary>
/// <param name="references">外层引用集合。</param>
/// <param name="matchedReferences">当前成功匹配分支采集到的引用。</param>
private static void AddUniqueReferenceUsages(
ICollection<YamlConfigReferenceUsage> references,
IEnumerable<YamlConfigReferenceUsage> matchedReferences)
{
foreach (var referenceUsage in matchedReferences)
{
if (!ContainsReferenceUsage(references, referenceUsage))
{
references.Add(referenceUsage);
}
}
}
/// <summary>
/// 判断外层引用集合中是否已经存在同一条引用使用记录。
/// </summary>
/// <param name="references">要检查的引用集合。</param>
/// <param name="candidate">当前待合并的引用记录。</param>
/// <returns>当集合中已存在语义相同的记录时返回 <see langword="true" />。</returns>
private static bool ContainsReferenceUsage(
IEnumerable<YamlConfigReferenceUsage> references,
YamlConfigReferenceUsage candidate)
{
foreach (var referenceUsage in references)
{
if (string.Equals(referenceUsage.YamlPath, candidate.YamlPath, StringComparison.Ordinal) &&
string.Equals(referenceUsage.SchemaPath, candidate.SchemaPath, StringComparison.Ordinal) &&
string.Equals(referenceUsage.PropertyPath, candidate.PropertyPath, StringComparison.Ordinal) &&
string.Equals(referenceUsage.RawValue, candidate.RawValue, StringComparison.Ordinal) &&
string.Equals(referenceUsage.ReferencedTableName, candidate.ReferencedTableName, StringComparison.Ordinal) &&
referenceUsage.ValueType == candidate.ValueType)
{
return true;
}
}
return false;
}
/// <summary>
/// 校验节点是否命中了 <c>not</c> 声明的禁用 schema。
/// 与 contains 不同not 会沿用主校验链的严格对象语义,避免把“声明属性子集”误当成完整命中。

View File

@ -584,6 +584,55 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证只有 object 节点允许声明 <c>dependentSchemas</c>。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_NonObject_Schema_Declares_DependentSchemas()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "tag"],
"properties": {
"id": { "type": "integer" },
"tag": {
"type": "string",
"dependentSchemas": {
"itemId": {
"type": "object",
"properties": {}
}
}
}
}
}
""";
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_011"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("tag"));
Assert.That(diagnostic.GetMessage(), Does.Contain("Only object schemas can declare 'dependentSchemas'."));
});
}
/// <summary>
/// 验证 <c>dependentSchemas</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。
/// </summary>

View File

@ -1742,9 +1742,8 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
}
if (schemaNode.dependentSchemas && typeof schemaNode.dependentSchemas === "object") {
for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) {
if (!yamlNode.map.has(triggerProperty) ||
matchesSchemaNode(dependentSchema, yamlNode, true)) {
for (const [triggerProperty, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) {
if (matchesSchemaNode(dependentSchema, yamlNode, true)) {
continue;
}
@ -1795,6 +1794,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca
validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer);
}
/**
* Enumerate object-level `dependentSchemas` entries whose trigger property is
* present on the current YAML object.
*
* @param {SchemaNode} schemaNode Schema node.
* @param {YamlNode} yamlNode YAML node.
* @returns {Array<[string, SchemaNode]>} Triggered dependent schema entries.
*/
function getTriggeredDependentSchemas(schemaNode, yamlNode) {
if (!schemaNode.dependentSchemas ||
typeof schemaNode.dependentSchemas !== "object" ||
!yamlNode ||
yamlNode.kind !== "object") {
return [];
}
const triggeredSchemas = [];
for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) {
if (yamlNode.map.has(triggerProperty)) {
triggeredSchemas.push([triggerProperty, dependentSchema]);
}
}
return triggeredSchemas;
}
/**
* Test whether one YAML node satisfies one schema node without emitting user-facing diagnostics.
* This is used by array `contains`, where object sub-schemas must behave like
@ -1869,12 +1894,9 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope
}
}
if (schemaNode.dependentSchemas && typeof schemaNode.dependentSchemas === "object") {
for (const [triggerProperty, dependentSchema] of Object.entries(schemaNode.dependentSchemas)) {
if (yamlNode.map.has(triggerProperty) &&
!matchesSchemaNodeInternal(dependentSchema, yamlNode, true)) {
return false;
}
for (const [, dependentSchema] of getTriggeredDependentSchemas(schemaNode, yamlNode)) {
if (!matchesSchemaNodeInternal(dependentSchema, yamlNode, true)) {
return false;
}
}