From d6a154726ce4706385bed380da24565be131d695 Mon Sep 17 00:00:00 2001 From: gewuyou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:42:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(game-config):=20=E6=98=BE=E5=BC=8F=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=20oneOf=20=E4=B8=8E=20anyOf=20=E7=BB=84=E5=90=88?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 Runtime、Source Generator 与 Tooling 对 oneOf/anyOf 的静默接受,统一改为显式报错 - 补充 JS 与 Release 测试回归,覆盖生成器诊断和运行时拒绝路径 - 更新 ai-plan 跟踪与中文文档,明确后续默认跳过会改变生成类型形状的组合关键字 --- .../AnalyzerReleases.Unshipped.md | 1 + .../Config/SchemaConfigGenerator.cs | 75 +++++++++++++++++++ .../Diagnostics/ConfigSchemaDiagnostics.cs | 11 +++ .../Config/YamlConfigLoaderAllOfTests.cs | 60 ++++++++++++++- .../Config/YamlConfigSchemaValidator.cs | 42 +++++++++++ .../Config/SchemaConfigGeneratorTests.cs | 48 ++++++++++++ ...st-config-system-csharp-experience-next.md | 11 +-- .../todos/ai-first-config-system-tracking.md | 9 ++- .../traces/ai-first-config-system-trace.md | 28 ++++++- docs/zh-CN/game/config-system.md | 1 + .../src/configValidation.js | 26 +++++++ .../test/configValidation.test.js | 27 +++++++ 12 files changed, 328 insertions(+), 11 deletions(-) diff --git a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md index 15a93c31..d2525c5d 100644 --- a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -19,3 +19,4 @@ GF_ConfigSchema_012 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_013 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_014 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_015 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 6d92383c..e8745044 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -232,6 +232,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } return TryValidateStringFormatMetadataRecursively(filePath, "", root, out diagnostic) && + TryValidateUnsupportedCombinatorKeywordsRecursively(filePath, "", root, out diagnostic) && TryValidateDependentRequiredMetadataRecursively(filePath, "", root, out diagnostic) && TryValidateDependentSchemasMetadataRecursively(filePath, "", root, out diagnostic) && TryValidateAllOfMetadataRecursively(filePath, "", root, out diagnostic) && @@ -844,6 +845,80 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator out diagnostic); } + /// + /// 递归拒绝当前共享子集尚未支持的组合关键字。 + /// 这里显式拦截 oneOf / anyOf,避免生成器静默接受会改变生成类型形状的 schema。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树是否未声明不支持的组合关键字。 + private static bool TryValidateUnsupportedCombinatorKeywordsRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, _) => + { + return TryValidateUnsupportedCombinatorKeywords( + currentFilePath, + currentDisplayPath, + currentElement, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + + /// + /// 验证当前节点是否声明了会改变生成类型形状的未支持组合关键字。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 未声明不支持关键字时返回 + private static bool TryValidateUnsupportedCombinatorKeywords( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (TryGetUnsupportedCombinatorKeywordName(element) is not { } keywordName) + { + return true; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedCombinatorKeyword, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + keywordName, + "The current config schema subset does not support combinators that can change generated type shape."); + return false; + } + + /// + /// 返回当前节点声明的首个未支持组合关键字。 + /// + /// 当前 schema 节点。 + /// 命中的关键字名称;未声明时返回空。 + private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element) + { + return element.TryGetProperty("oneOf", out _) ? "oneOf" : + element.TryGetProperty("anyOf", out _) ? "anyOf" : + null; + } + /// /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 /// 该遍历覆盖对象属性、dependentSchemas / allOf / diff --git a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index adbbdd41..f54b4473 100644 --- a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -162,4 +162,15 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 节点声明了当前共享子集尚未支持的组合关键字。 + /// + public static readonly DiagnosticDescriptor UnsupportedCombinatorKeyword = new( + "GF_ConfigSchema_015", + "Config schema uses an unsupported combinator keyword", + "Property '{1}' in schema file '{0}' uses unsupported combinator keyword '{2}': {3}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs index 1e4da8ff..6945701c 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs @@ -270,6 +270,60 @@ public sealed class YamlConfigLoaderAllOfTests }); } + /// + /// 验证运行时会显式拒绝当前共享子集尚未支持的 oneOf。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Schema_Declares_Unsupported_OneOf() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + """, + """ + "oneOf": [ + { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "type": "integer" } + } + } + ] + """)); + + 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 combinator keyword 'oneOf'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证 allOf 条目只接受 object-typed schema。 /// @@ -566,10 +620,12 @@ public sealed class YamlConfigLoaderAllOfTests /// /// 奖励对象的 properties JSON 片段。 /// allOf 约束的 JSON 数组片段。 + /// 追加到奖励对象上的额外关键字 JSON 片段。 /// 完整的 schema JSON 文本。 private static string BuildMonsterSchema( string rewardPropertiesJson, - string allOfJson) + string allOfJson, + string additionalRewardKeywordsJson = "") { return $$""" { @@ -580,7 +636,7 @@ public sealed class YamlConfigLoaderAllOfTests "reward": { "type": "object", "properties": {{rewardPropertiesJson}}, - "allOf": {{allOfJson}} + "allOf": {{allOfJson}}{{(string.IsNullOrWhiteSpace(additionalRewardKeywordsJson) ? string.Empty : "," + Environment.NewLine + additionalRewardKeywordsJson.Trim())}} } } } diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 3aaf1d0d..b97ea2af 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -321,6 +321,7 @@ internal static partial class YamlConfigSchemaValidator JsonElement element, bool isRoot = false) { + ValidateUnsupportedCombinatorKeywords(tableName, schemaPath, propertyPath, element); var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element); var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName); @@ -336,6 +337,47 @@ internal static partial class YamlConfigSchemaValidator return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element)); } + /// + /// 显式拒绝当前共享子集中尚未支持、且会改变生成类型形状的组合关键字。 + /// 这样 Runtime / Generator / Tooling 会对同一份 schema 给出一致失败, + /// 而不是默默忽略 oneOf / anyOf 造成接受范围漂移。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 当前节点的逻辑属性路径。 + /// 当前 schema 节点。 + private static void ValidateUnsupportedCombinatorKeywords( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + if (TryGetUnsupportedCombinatorKeywordName(element) is not { } keywordName) + { + return; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares unsupported combinator keyword '{keywordName}'. " + + "The current config schema subset does not support combinators that can change generated type shape.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + /// + /// 返回当前节点声明的首个未支持组合关键字。 + /// + /// 当前 schema 节点。 + /// 命中的关键字名称;未声明时返回空。 + private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element) + { + return element.TryGetProperty("oneOf", out _) ? "oneOf" : + element.TryGetProperty("anyOf", out _) ? "anyOf" : + null; + } + /// /// 解析 schema 节点声明的类型名称,并在缺失或类型错误时立刻给出定位清晰的诊断。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 9826693f..2f19b24c 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -1795,6 +1795,54 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证生成器会显式拒绝当前共享子集尚未支持的 oneOf。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_OneOf() + { + const string source = DummySource; + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "oneOf": [ + { + "type": "object", + "required": ["itemCount"], + "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_015")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("oneOf")); + Assert.That(diagnostic.GetMessage(), Does.Contain("does not support combinators that can change generated type shape")); + }); + } + /// /// 验证 then 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 /// diff --git a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md index c45850ab..8b976e54 100644 --- a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md +++ b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md @@ -37,8 +37,8 @@ - [x] 继续扩展最有价值的 JSON Schema 子集 - 原则:只做 Runtime / Generator / Tooling 三端都能稳定解释的关键字 - - 已补齐:`enum`(当前覆盖标量、对象、数组节点,以及标量数组元素)、`const`、`not`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else` - - 当前产出:运行时拒绝相关约束违规值,VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf` 与 `if` / `then` / `else` 当前都收敛为 object-focused constraint block,不做属性合并 + - 已补齐:`enum`(当前覆盖标量、对象、数组节点,以及标量数组元素)、`const`、`not`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minLength`、`maxLength`、`minItems`、`maxItems`、`contains`、`minContains`、`maxContains`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else` + - 当前产出:运行时拒绝相关约束违规值,VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf` 与 `if` / `then` / `else` 当前都收敛为 object-focused constraint block,不做属性合并;`oneOf` / `anyOf` 当前已统一定义为不支持并在三端显式拒绝 - [x] 评估可选只读索引能力 - 目标:为高频查询字段提供比 `All()` 线性扫描更强的读取体验 @@ -63,7 +63,7 @@ ## 暂缓 - [ ] 不追求完整 JSON Schema 全量支持 - - 原因:维护成本高,且容易造成 Runtime / Generator / Tooling 三端漂移 + - 原因:维护成本高,且容易造成 Runtime / Generator / Tooling 三端漂移;像 `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前已明确排除 - [ ] 不优先做运行时可写配置 - 原因:当前系统定位仍然是静态内容只读查询 @@ -87,6 +87,7 @@ ## 下次恢复点 - 在当前稳定 `format` 子集(`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、object-focused `allOf` 与 object-focused `if` / `then` / `else` 之后,转到下一批仍不改变生成类型形状的关键字评估;仍然不要先回工具 UI +- `oneOf` / `anyOf` 已明确跳过;恢复时不要再把它们当作默认候选 - 恢复时优先检查: - `GFramework.Game/Config/YamlConfigSchemaValidator.cs` - `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` @@ -108,5 +109,5 @@ - 结果:通过 - 下一步: 1. 检查 `YamlConfigSchemaValidator.cs`、`SchemaConfigGenerator.cs`、`configValidation.js` 中当前已支持的关键字列表 - 2. 评估 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集 - 3. 若结论否定,选择下一批共享解释关键字而不是先回工具 UI + 2. 跳过 `oneOf` / `anyOf`,选择下一批仍不改变生成类型形状的共享关键字 + 3. 优先找不需要属性合并、联合分支生成或额外 UI 形状解释的关键字,而不是先回工具 UI diff --git a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md index aca43ecc..b70f768d 100644 --- a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md +++ b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md @@ -11,6 +11,7 @@ - 当前阶段:`C# Runtime + Source Generator + Consumer DX` - 当前焦点: - 已完成 object-focused `if` / `then` / `else`,继续评估下一批仍不改变生成类型形状的共享关键字 + - 已明确将 `oneOf` / `anyOf` 归类为当前不支持的组合关键字,并在 Runtime / Generator / Tooling 三端显式拒绝,避免静默接受导致形状漂移 - 已完成 PR #262 的 CodeRabbit follow-up,补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests - 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema - 继续把 VS Code 工具能力视为非阻塞项,不让复杂 UI 编辑器需求反过来拖慢 C# 主线 @@ -18,7 +19,7 @@ ### 已知风险 - 组合关键字扩展风险:下一批候选关键字可能像标准 `oneOf` / `anyOf` 一样更容易引入生成类型形状漂移 - - 缓解措施:延续 object-focused / focused matcher 约束,只接受三端都能稳定解释且不需要属性合并的子集 + - 缓解措施:`oneOf` / `anyOf` 已改为三端显式拒绝;后续仅继续评估不会引入联合形状、属性合并或分支生成漂移的关键字子集 - 工具链验证风险:VS Code 与 CI / 发布管道验证覆盖不足 - 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过,并记录 JS 测试与构建验证 - PR review 信号漂移风险:CodeRabbit 可能把建议折叠在 latest review body,而不是 issue comments @@ -35,8 +36,10 @@ - 已补齐一批共享 JSON Schema 子集,包括: - `enum`、`const`、`not`、`pattern` - `format` 稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid` - - `minItems`、`maxItems`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems` + - `minLength`、`maxLength`、`minItems`、`maxItems`、`contains`、`minContains`、`maxContains`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems` - `minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else` +- 已明确拒绝会改变生成类型形状的组合关键字: + - `oneOf`、`anyOf` 当前会在 Runtime / Generator / Tooling 三端直接报错,而不是静默忽略 - `if` / `then` / `else` 已按“不改变生成类型形状”的边界落地: - 只允许 object 节点上的 object-typed inline schema - `if` 必填,且必须至少伴随 `then` 或 `else` 之一 @@ -95,4 +98,4 @@ 1. 提交并推送当前 PR `#262` follow-up 修复后,重新抓取一次 PR review,确认 outside-diff comment 与 open thread 是否都已收口 2. 若 PR review 已收口,再回到 `GFramework.Game/Config/YamlConfigSchemaValidator.cs`、`GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs`、`tools/gframework-config-tool/src/configValidation.js` 盘点下一批候选关键字 -3. 优先判断 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集;若仍会引入生成类型形状漂移,就直接跳过 +3. 跳过 `oneOf` / `anyOf`,优先筛选下一个仍不改变生成类型形状、且不需要属性合并或联合分支生成的共享关键字 diff --git a/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md b/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md index e59b4c93..72ff88df 100644 --- a/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md +++ b/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md @@ -106,6 +106,32 @@ ### 下一步 -1. 评估 `oneOf` / `anyOf` 是否值得继续沿用 object-focused 子集;若仍会造成生成形状漂移,就直接跳过 +1. 跳过 `oneOf` / `anyOf`,优先筛选下一个仍不改变生成类型形状、且不需要属性合并或联合分支生成的共享关键字 2. 若继续扩共享关键字,先在 Runtime / Generator / Tooling 三端同时定义一致边界,再进入实现 3. 继续把 active 入口保持精简,只记录当前恢复点、验证与下一步 + +## 2026-04-30 + +### 阶段:组合关键字边界收口(AI-FIRST-CONFIG-RP-003) + +- 已在 Runtime、Source Generator 与 VS Code Tooling 三端显式拒绝 `oneOf` / `anyOf` +- 本轮结论不是继续做 object-focused 子集,而是先收紧共享边界: + - `oneOf` / `anyOf` 更容易引入联合分支、属性合并或生成类型形状漂移 + - 当前配置系统主线仍优先保证 `C# Runtime + Source Generator + Consumer DX` 的稳定契约 + - 因此三端统一改为在 schema 解析 / 生成阶段直接失败,避免静默忽略同一份 schema +- active tracking 也已同步更新,不再把 `oneOf` / `anyOf` 作为下一批默认候选 + +### 验证 + +- 2026-04-30:`bun run test`(`tools/gframework-config-tool`) + - 目标:验证工具端会拒绝 `oneOf` +- 2026-04-30:`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"` + - 目标:验证生成器新增 `GF_ConfigSchema_015` +- 2026-04-30:`dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderAllOfTests"` + - 目标:验证运行时会拒绝对象节点上的 `oneOf` + +### 下一步 + +1. 若本轮定向验证通过,继续盘点下一批真正低风险、且不改变生成类型形状的共享关键字 +2. 不再重复评估 `oneOf` / `anyOf` 的 object-focused 子集,除非未来主线明确接受联合形状生成 +3. 若后续关键字需要新诊断编号或文档边界说明,继续保持 Runtime / Generator / Tooling 同步收口 diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 1f1ca949..64331588 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -802,6 +802,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `dependentSchemas`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受“已声明 sibling 字段触发 object 子 schema”的形状,不改变生成类型形状,并按 focused constraint block 语义允许条件子 schema 未声明的额外同级字段继续存在 - `allOf`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受 object 节点上的 object-typed inline schema 数组,并按 focused constraint block 语义把每个条目叠加到当前对象上,不做属性合并,也不改变生成类型形状 - `if` / `then` / `else`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受 object 节点上的 object-typed inline schema,`if` 必填且必须至少配合 `then` 或 `else` 之一使用,分支只能约束父对象已声明的字段,不做属性合并,也不改变生成类型形状;条件匹配本身沿用 `dependentSchemas` / `allOf` 的 focused matcher 语义,允许对象保留未在条件块中声明的额外同级字段 +- `oneOf` / `anyOf`:当前不属于共享支持子集;Runtime / Generator / Tooling 会在解析或生成阶段直接拒绝,避免静默接受会改变生成类型形状的组合关键字 `allOf` 的最小可工作示例如下。关键点是:字段形状先在父对象 `properties` 中声明,再用 `allOf` 叠加 `required` 或更细的字段约束;`allOf` 条目不会把新字段并回父对象。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 6c7b193c..926c73c8 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1095,6 +1095,13 @@ function unquoteScalar(value) { */ function parseSchemaNode(rawNode, displayPath) { const value = rawNode && typeof rawNode === "object" ? rawNode : {}; + const unsupportedCombinatorKeyword = getUnsupportedCombinatorKeywordName(value); + if (unsupportedCombinatorKeyword) { + throw new Error( + `Schema property '${displayPath}' declares unsupported combinator keyword '${unsupportedCombinatorKeyword}'. ` + + "The current config schema subset does not support combinators that can change generated type shape."); + } + const type = typeof value.type === "string" ? value.type : "object"; const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath); const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath); @@ -1253,6 +1260,25 @@ function parseSchemaNode(rawNode, displayPath) { }, value.const, displayPath), value.enum, displayPath); } +/** + * Return the first combinator keyword that the current shared schema subset + * intentionally rejects to keep Runtime / Generator / Tooling behavior aligned. + * + * @param {Record} schemaNode Raw schema object. + * @returns {string | undefined} Unsupported keyword name when present. + */ +function getUnsupportedCombinatorKeywordName(schemaNode) { + if (Object.prototype.hasOwnProperty.call(schemaNode, "oneOf")) { + return "oneOf"; + } + + if (Object.prototype.hasOwnProperty.call(schemaNode, "anyOf")) { + return "anyOf"; + } + + return undefined; +} + /** * Parse one optional `not` sub-schema and keep path formatting aligned with * the runtime/generator diagnostics. diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index dcfe00ab..312cacff 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -178,6 +178,33 @@ test("parseSchemaContent should preserve empty-string const raw and display meta assert.equal(schema.properties.name.constDisplayValue, "\"\""); }); +test("parseSchemaContent should reject unsupported oneOf combinators", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "oneOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + } + } + } + `), + /unsupported combinator keyword 'oneOf'/u); +}); + test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => { const schema = parseSchemaContent(` {