From 13b77eb3fe569f0bb3cfd38f8c717844b72b648c Mon Sep 17 00:00:00 2001 From: gewuyou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:36:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(game-config):=20=E6=98=BE=E5=BC=8F=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E9=97=AD=E5=90=88=E5=AF=B9=E8=B1=A1=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 Runtime 与 Source Generator 对 additionalProperties 的隐式闭合对象语义,统一接受 additionalProperties:false 并拒绝其它开放对象形状 - 补充 Release 回归测试,覆盖生成器诊断与运行时 additionalProperties 边界 - 更新配置工具元数据与 README 说明,使命令、设置和当前能力描述保持一致 --- .../AnalyzerReleases.Unshipped.md | 1 + .../Config/SchemaConfigGenerator.cs | 69 ++++++++++++++++++ .../Diagnostics/ConfigSchemaDiagnostics.cs | 11 +++ .../Config/YamlConfigLoaderAllOfTests.cs | 67 +++++++++++++++++ .../Config/YamlConfigSchemaValidator.cs | 35 +++++++++ .../Config/SchemaConfigGeneratorTests.cs | 71 +++++++++++++++++++ tools/gframework-config-tool/README.md | 20 ++++-- tools/gframework-config-tool/package.nls.json | 8 +-- .../package.nls.zh-cn.json | 8 +-- 9 files changed, 277 insertions(+), 13 deletions(-) diff --git a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md index d2525c5d..53562b2d 100644 --- a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -20,3 +20,4 @@ GF_ConfigSchema_013 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_014 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_015 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_016 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index e8745044..eeedde14 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -233,6 +233,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return TryValidateStringFormatMetadataRecursively(filePath, "", root, out diagnostic) && TryValidateUnsupportedCombinatorKeywordsRecursively(filePath, "", root, out diagnostic) && + TryValidateUnsupportedOpenObjectKeywordsRecursively(filePath, "", root, out diagnostic) && TryValidateDependentRequiredMetadataRecursively(filePath, "", root, out diagnostic) && TryValidateDependentSchemasMetadataRecursively(filePath, "", root, out diagnostic) && TryValidateAllOfMetadataRecursively(filePath, "", root, out diagnostic) && @@ -877,6 +878,39 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator out diagnostic); } + /// + /// 递归拒绝当前共享子集尚未支持的开放对象关键字形状。 + /// 当前对象字段集默认是闭合的,因此这里只接受显式重复该语义的 + /// additionalProperties: false。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树是否未声明不支持的开放对象关键字形状。 + private static bool TryValidateUnsupportedOpenObjectKeywordsRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, _) => + { + return TryValidateUnsupportedOpenObjectKeywords( + currentFilePath, + currentDisplayPath, + currentElement, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + /// /// 验证当前节点是否声明了会改变生成类型形状的未支持组合关键字。 /// @@ -907,6 +941,41 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } + /// + /// 验证当前节点是否声明了当前共享子集尚未支持的开放对象关键字形状。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 未声明不支持关键字时返回 + private static bool TryValidateUnsupportedOpenObjectKeywords( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("additionalProperties", out var additionalPropertiesElement)) + { + return true; + } + + if (additionalPropertiesElement.ValueKind == JsonValueKind.False) + { + return true; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedOpenObjectKeyword, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "additionalProperties", + "The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed."); + return false; + } + /// /// 返回当前节点声明的首个未支持组合关键字。 /// diff --git a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index f54b4473..64c80336 100644 --- a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -173,4 +173,15 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 节点声明了当前共享子集尚未支持的开放对象关键字形状。 + /// + public static readonly DiagnosticDescriptor UnsupportedOpenObjectKeyword = new( + "GF_ConfigSchema_016", + "Config schema uses an unsupported open-object keyword", + "Property '{1}' in schema file '{0}' uses unsupported open-object keyword '{2}': {3}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs index 6945701c..af840c96 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs @@ -324,6 +324,73 @@ public sealed class YamlConfigLoaderAllOfTests }); } + /// + /// 验证运行时接受显式声明的 additionalProperties: false, + /// 因为这与当前闭合对象字段集语义保持一致。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_Object_Schema_Declares_AdditionalProperties_False() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + DefaultAllOfJson, + """ + "additionalProperties": false + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry).ConfigureAwait(false); + + var table = registry.GetTable("monster"); + Assert.That(table.Count, Is.EqualTo(1)); + } + + /// + /// 验证运行时会拒绝会打开动态字段形状的 additionalProperties。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Schema_Declares_Unsupported_AdditionalProperties() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + DefaultAllOfJson, + """ + "additionalProperties": true + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(() => 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("unsupported 'additionalProperties' metadata")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证 allOf 条目只接受 object-typed schema。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index b97ea2af..37a9d622 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -322,6 +322,7 @@ internal static partial class YamlConfigSchemaValidator bool isRoot = false) { ValidateUnsupportedCombinatorKeywords(tableName, schemaPath, propertyPath, element); + ValidateUnsupportedOpenObjectKeywords(tableName, schemaPath, propertyPath, element); var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element); var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName); @@ -366,6 +367,40 @@ internal static partial class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } + /// + /// 显式拒绝当前共享子集中尚未支持的开放对象关键字形状。 + /// 当前配置系统默认采用闭合对象字段集;这里只接受显式重复该语义的 + /// additionalProperties: false,继续拒绝会引入动态字段形状的其它形式。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 当前节点的逻辑属性路径。 + /// 当前 schema 节点。 + private static void ValidateUnsupportedOpenObjectKeywords( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + if (!element.TryGetProperty("additionalProperties", out var additionalPropertiesElement)) + { + return; + } + + if (additionalPropertiesElement.ValueKind == JsonValueKind.False) + { + return; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported 'additionalProperties' metadata. " + + "The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + /// /// 返回当前节点声明的首个未支持组合关键字。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 2f19b24c..cbde49a3 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -1843,6 +1843,77 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证生成器接受显式声明的 additionalProperties: false。 + /// + [Test] + public void Run_Should_Accept_When_Object_Schema_Declares_AdditionalProperties_False() + { + const string source = DummySource; + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "additionalProperties": false, + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + } + + /// + /// 验证生成器会拒绝会打开动态字段形状的 additionalProperties。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AdditionalProperties() + { + const string source = DummySource; + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "additionalProperties": true, + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + """; + + 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_016")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("additionalProperties")); + Assert.That(diagnostic.GetMessage(), Does.Contain("only accepts 'additionalProperties: false'")); + }); + } + /// /// 验证 then 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 /// diff --git a/tools/gframework-config-tool/README.md b/tools/gframework-config-tool/README.md index 5e72a699..c4554416 100644 --- a/tools/gframework-config-tool/README.md +++ b/tools/gframework-config-tool/README.md @@ -1,6 +1,6 @@ # GFramework Config Tool -VS Code extension for the GFramework AI-First config workflow. +VS Code extension for browsing, validating, and lightweight editing in the GFramework AI-First config workflow. ## Purpose @@ -34,7 +34,7 @@ GameProject/ ### Explorer View -- Browse config files from the workspace `config/` directory +- Browse config files from the first workspace folder's `config/` directory - Group files by config domain - Open matching schema files from `schemas/` @@ -43,11 +43,12 @@ GameProject/ - Open raw YAML - Open the matching schema - Open a lightweight form preview +- Revalidate saved config files automatically when they change ### Domain-Level Actions - Batch edit one config domain across multiple files for top-level scalar and scalar-array fields -- Run validation across the current workspace config surface +- Validate all discovered config files from the explorer view ### Form / Validation Support @@ -56,6 +57,8 @@ GameProject/ - Jump from reference fields to the referenced schema, config domain, or direct config file when a reference value is present - Initialize empty config files from schema-derived example YAML +- Edit nested object fields recursively inside the form preview +- Edit arrays of objects in the form preview, including nested object fields inside each item - Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the lightweight editors @@ -69,6 +72,12 @@ The extension currently validates the repository's current schema subset: - scalar arrays with scalar item type checks - arrays of objects whose items use the same supported subset recursively - scalar `enum` constraints and scalar-array item `enum` constraints +- scalar `const` constraints +- numeric range constraints such as `minimum`, `exclusiveMinimum`, `maximum`, `exclusiveMaximum`, and `multipleOf` +- string constraints such as `minLength`, `maxLength`, and `pattern` +- array constraints such as `minItems`, `maxItems`, `contains`, `minContains`, `maxContains`, and `uniqueItems` +- object constraints such as `minProperties`, `maxProperties`, `dependentRequired`, `dependentSchemas`, `allOf`, and + object-focused `if` / `then` / `else` ## Workspace Settings @@ -83,7 +92,7 @@ The extension currently validates the repository's current schema subset: 1. Install the extension in VS Code and open the workspace that contains your `config/` and `schemas/` directories. 2. Keep the default workspace layout, or set `gframeworkConfig.configPath` and `gframeworkConfig.schemasPath` to your - project-specific paths. + project-specific paths relative to the first workspace folder. 3. Open the `GFramework Config` explorer view and select a config file or domain. 4. Run validation first to confirm the current YAML files still match the supported schema subset. 5. Open the lightweight form preview or domain batch editing actions, then fall back to raw YAML for deeper nested edits @@ -98,7 +107,8 @@ The extension currently validates the repository's current schema subset: - Multi-root workspaces use the first workspace folder - Validation only covers the repository's current schema subset -- Form preview supports object-array editing, but nested object arrays inside array items still fall back to raw YAML +- Form preview supports nested objects and object-array editing, but deeper nested object arrays inside array items still + fall back to raw YAML - Batch editing remains limited to top-level scalar fields and top-level scalar arrays ## Local Testing diff --git a/tools/gframework-config-tool/package.nls.json b/tools/gframework-config-tool/package.nls.json index a7acaa0f..33c4355a 100644 --- a/tools/gframework-config-tool/package.nls.json +++ b/tools/gframework-config-tool/package.nls.json @@ -1,14 +1,14 @@ { "extension.displayName": "GFramework Config Tool", - "extension.description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.", + "extension.description": "VS Code tooling for browsing, validating, form-preview editing, and domain batch updates for AI-First config files in GFramework projects.", "view.gframeworkConfig.name": "GFramework Config", "command.refresh.title": "GFramework Config: Refresh", - "command.openRaw.title": "GFramework Config: Open Raw File", + "command.openRaw.title": "GFramework Config: Open Raw YAML", "command.openSchema.title": "GFramework Config: Open Schema", "command.openFormPreview.title": "GFramework Config: Open Form Preview", "command.batchEditDomain.title": "GFramework Config: Batch Edit Domain", "command.validateAll.title": "GFramework Config: Validate All", "configuration.title": "GFramework Config", - "configuration.configPath.description": "Relative path from the workspace root to the config directory.", - "configuration.schemasPath.description": "Relative path from the workspace root to the schema directory." + "configuration.configPath.description": "Relative path from the first workspace folder to the config directory.", + "configuration.schemasPath.description": "Relative path from the first workspace folder to the schema directory." } diff --git a/tools/gframework-config-tool/package.nls.zh-cn.json b/tools/gframework-config-tool/package.nls.zh-cn.json index bd067a59..b36368f6 100644 --- a/tools/gframework-config-tool/package.nls.zh-cn.json +++ b/tools/gframework-config-tool/package.nls.zh-cn.json @@ -1,14 +1,14 @@ { "extension.displayName": "GFramework 配置工具", - "extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验和编辑能力的 VS Code 扩展。", + "extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验、表单预览编辑和配置域批量更新能力的 VS Code 扩展。", "view.gframeworkConfig.name": "GFramework 配置", "command.refresh.title": "GFramework 配置:刷新", - "command.openRaw.title": "GFramework 配置:打开原始文件", + "command.openRaw.title": "GFramework 配置:打开原始 YAML", "command.openSchema.title": "GFramework 配置:打开 Schema", "command.openFormPreview.title": "GFramework 配置:打开表单预览", "command.batchEditDomain.title": "GFramework 配置:批量编辑配置域", "command.validateAll.title": "GFramework 配置:校验全部", "configuration.title": "GFramework 配置", - "configuration.configPath.description": "从工作区根目录到配置目录的相对路径。", - "configuration.schemasPath.description": "从工作区根目录到 Schema 目录的相对路径。" + "configuration.configPath.description": "从第一个工作区目录到配置目录的相对路径。", + "configuration.schemasPath.description": "从第一个工作区目录到 Schema 目录的相对路径。" }