fix(game-config): 显式拒绝 oneOf 与 anyOf 组合关键字

- 修复 Runtime、Source Generator 与 Tooling 对 oneOf/anyOf 的静默接受,统一改为显式报错

- 补充 JS 与 Release 测试回归,覆盖生成器诊断和运行时拒绝路径

- 更新 ai-plan 跟踪与中文文档,明确后续默认跳过会改变生成类型形状的组合关键字
This commit is contained in:
gewuyou 2026-04-30 10:42:07 +08:00 committed by GeWuYou
parent 8d6fc74b3d
commit d6a154726c
12 changed files with 328 additions and 11 deletions

View File

@ -19,3 +19,4 @@
GF_ConfigSchema_012 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_012 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_013 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_013 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_014 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_014 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_015 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics

View File

@ -232,6 +232,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
} }
return TryValidateStringFormatMetadataRecursively(filePath, "<root>", root, out diagnostic) && return TryValidateStringFormatMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateUnsupportedCombinatorKeywordsRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateDependentRequiredMetadataRecursively(filePath, "<root>", root, out diagnostic) && TryValidateDependentRequiredMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateDependentSchemasMetadataRecursively(filePath, "<root>", root, out diagnostic) && TryValidateDependentSchemasMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateAllOfMetadataRecursively(filePath, "<root>", root, out diagnostic) && TryValidateAllOfMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
@ -844,6 +845,80 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
out diagnostic); out diagnostic);
} }
/// <summary>
/// 递归拒绝当前共享子集尚未支持的组合关键字。
/// 这里显式拦截 <c>oneOf</c> / <c>anyOf</c>,避免生成器静默接受会改变生成类型形状的 schema。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点树是否未声明不支持的组合关键字。</returns>
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);
}
/// <summary>
/// 验证当前节点是否声明了会改变生成类型形状的未支持组合关键字。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>未声明不支持关键字时返回 <see langword="true" />。</returns>
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;
}
/// <summary>
/// 返回当前节点声明的首个未支持组合关键字。
/// </summary>
/// <param name="element">当前 schema 节点。</param>
/// <returns>命中的关键字名称;未声明时返回空。</returns>
private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element)
{
return element.TryGetProperty("oneOf", out _) ? "oneOf" :
element.TryGetProperty("anyOf", out _) ? "anyOf" :
null;
}
/// <summary> /// <summary>
/// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>allOf</c> / /// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>allOf</c> /

View File

@ -162,4 +162,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory, SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error, DiagnosticSeverity.Error,
true); true);
/// <summary>
/// schema 节点声明了当前共享子集尚未支持的组合关键字。
/// </summary>
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);
} }

View File

@ -270,6 +270,60 @@ public sealed class YamlConfigLoaderAllOfTests
}); });
} }
/// <summary>
/// 验证运行时会显式拒绝当前共享子集尚未支持的 <c>oneOf</c>。
/// </summary>
[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<ConfigLoadException>(() => 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));
});
}
/// <summary> /// <summary>
/// 验证 allOf 条目只接受 object-typed schema。 /// 验证 allOf 条目只接受 object-typed schema。
/// </summary> /// </summary>
@ -566,10 +620,12 @@ public sealed class YamlConfigLoaderAllOfTests
/// </summary> /// </summary>
/// <param name="rewardPropertiesJson">奖励对象的 properties JSON 片段。</param> /// <param name="rewardPropertiesJson">奖励对象的 properties JSON 片段。</param>
/// <param name="allOfJson">allOf 约束的 JSON 数组片段。</param> /// <param name="allOfJson">allOf 约束的 JSON 数组片段。</param>
/// <param name="additionalRewardKeywordsJson">追加到奖励对象上的额外关键字 JSON 片段。</param>
/// <returns>完整的 schema JSON 文本。</returns> /// <returns>完整的 schema JSON 文本。</returns>
private static string BuildMonsterSchema( private static string BuildMonsterSchema(
string rewardPropertiesJson, string rewardPropertiesJson,
string allOfJson) string allOfJson,
string additionalRewardKeywordsJson = "")
{ {
return $$""" return $$"""
{ {
@ -580,7 +636,7 @@ public sealed class YamlConfigLoaderAllOfTests
"reward": { "reward": {
"type": "object", "type": "object",
"properties": {{rewardPropertiesJson}}, "properties": {{rewardPropertiesJson}},
"allOf": {{allOfJson}} "allOf": {{allOfJson}}{{(string.IsNullOrWhiteSpace(additionalRewardKeywordsJson) ? string.Empty : "," + Environment.NewLine + additionalRewardKeywordsJson.Trim())}}
} }
} }
} }

View File

@ -321,6 +321,7 @@ internal static partial class YamlConfigSchemaValidator
JsonElement element, JsonElement element,
bool isRoot = false) bool isRoot = false)
{ {
ValidateUnsupportedCombinatorKeywords(tableName, schemaPath, propertyPath, element);
var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element); var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element);
var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element);
ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName); ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName);
@ -336,6 +337,47 @@ internal static partial class YamlConfigSchemaValidator
return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element)); return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element));
} }
/// <summary>
/// 显式拒绝当前共享子集中尚未支持、且会改变生成类型形状的组合关键字。
/// 这样 Runtime / Generator / Tooling 会对同一份 schema 给出一致失败,
/// 而不是默默忽略 <c>oneOf</c> / <c>anyOf</c> 造成接受范围漂移。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点的逻辑属性路径。</param>
/// <param name="element">当前 schema 节点。</param>
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));
}
/// <summary>
/// 返回当前节点声明的首个未支持组合关键字。
/// </summary>
/// <param name="element">当前 schema 节点。</param>
/// <returns>命中的关键字名称;未声明时返回空。</returns>
private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element)
{
return element.TryGetProperty("oneOf", out _) ? "oneOf" :
element.TryGetProperty("anyOf", out _) ? "anyOf" :
null;
}
/// <summary> /// <summary>
/// 解析 schema 节点声明的类型名称,并在缺失或类型错误时立刻给出定位清晰的诊断。 /// 解析 schema 节点声明的类型名称,并在缺失或类型错误时立刻给出定位清晰的诊断。
/// </summary> /// </summary>

View File

@ -1795,6 +1795,54 @@ public class SchemaConfigGeneratorTests
}); });
} }
/// <summary>
/// 验证生成器会显式拒绝当前共享子集尚未支持的 <c>oneOf</c>。
/// </summary>
[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"));
});
}
/// <summary> /// <summary>
/// 验证 <c>then</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。 /// 验证 <c>then</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。
/// </summary> /// </summary>

View File

@ -37,8 +37,8 @@
- [x] 继续扩展最有价值的 JSON Schema 子集 - [x] 继续扩展最有价值的 JSON Schema 子集
- 原则:只做 Runtime / Generator / Tooling 三端都能稳定解释的关键字 - 原则:只做 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` - 已补齐:`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不做属性合并 - 当前产出运行时拒绝相关约束违规值VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf``if` / `then` / `else` 当前都收敛为 object-focused constraint block不做属性合并`oneOf` / `anyOf` 当前已统一定义为不支持并在三端显式拒绝
- [x] 评估可选只读索引能力 - [x] 评估可选只读索引能力
- 目标:为高频查询字段提供比 `All()` 线性扫描更强的读取体验 - 目标:为高频查询字段提供比 `All()` 线性扫描更强的读取体验
@ -63,7 +63,7 @@
## 暂缓 ## 暂缓
- [ ] 不追求完整 JSON Schema 全量支持 - [ ] 不追求完整 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 - 在当前稳定 `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/Config/YamlConfigSchemaValidator.cs`
- `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` - `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs`
@ -108,5 +109,5 @@
- 结果:通过 - 结果:通过
- 下一步: - 下一步:
1. 检查 `YamlConfigSchemaValidator.cs``SchemaConfigGenerator.cs``configValidation.js` 中当前已支持的关键字列表 1. 检查 `YamlConfigSchemaValidator.cs``SchemaConfigGenerator.cs``configValidation.js` 中当前已支持的关键字列表
2. 评估 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集 2. 跳过 `oneOf` / `anyOf`,选择下一批仍不改变生成类型形状的共享关键字
3. 若结论否定,选择下一批共享解释关键字而不是先回工具 UI 3. 优先找不需要属性合并、联合分支生成或额外 UI 形状解释的关键字,而不是先回工具 UI

View File

@ -11,6 +11,7 @@
- 当前阶段:`C# Runtime + Source Generator + Consumer DX` - 当前阶段:`C# Runtime + Source Generator + Consumer DX`
- 当前焦点: - 当前焦点:
- 已完成 object-focused `if` / `then` / `else`,继续评估下一批仍不改变生成类型形状的共享关键字 - 已完成 object-focused `if` / `then` / `else`,继续评估下一批仍不改变生成类型形状的共享关键字
- 已明确将 `oneOf` / `anyOf` 归类为当前不支持的组合关键字,并在 Runtime / Generator / Tooling 三端显式拒绝,避免静默接受导致形状漂移
- 已完成 PR #262 的 CodeRabbit follow-up补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests - 已完成 PR #262 的 CodeRabbit follow-up补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests
- 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema - 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema
- 继续把 VS Code 工具能力视为非阻塞项,不让复杂 UI 编辑器需求反过来拖慢 C# 主线 - 继续把 VS Code 工具能力视为非阻塞项,不让复杂 UI 编辑器需求反过来拖慢 C# 主线
@ -18,7 +19,7 @@
### 已知风险 ### 已知风险
- 组合关键字扩展风险:下一批候选关键字可能像标准 `oneOf` / `anyOf` 一样更容易引入生成类型形状漂移 - 组合关键字扩展风险:下一批候选关键字可能像标准 `oneOf` / `anyOf` 一样更容易引入生成类型形状漂移
- 缓解措施:延续 object-focused / focused matcher 约束,只接受三端都能稳定解释且不需要属性合并的子集 - 缓解措施:`oneOf` / `anyOf` 已改为三端显式拒绝;后续仅继续评估不会引入联合形状、属性合并或分支生成漂移的关键字子集
- 工具链验证风险VS Code 与 CI / 发布管道验证覆盖不足 - 工具链验证风险VS Code 与 CI / 发布管道验证覆盖不足
- 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过,并记录 JS 测试与构建验证 - 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过,并记录 JS 测试与构建验证
- PR review 信号漂移风险CodeRabbit 可能把建议折叠在 latest review body而不是 issue comments - PR review 信号漂移风险CodeRabbit 可能把建议折叠在 latest review body而不是 issue comments
@ -35,8 +36,10 @@
- 已补齐一批共享 JSON Schema 子集,包括: - 已补齐一批共享 JSON Schema 子集,包括:
- `enum``const``not``pattern` - `enum``const``not``pattern`
- `format` 稳定子集:`date``date-time``duration``email``time``uri``uuid` - `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` - `minProperties``maxProperties``dependentRequired``dependentSchemas``allOf`、object-focused `if` / `then` / `else`
- 已明确拒绝会改变生成类型形状的组合关键字:
- `oneOf``anyOf` 当前会在 Runtime / Generator / Tooling 三端直接报错,而不是静默忽略
- `if` / `then` / `else` 已按“不改变生成类型形状”的边界落地: - `if` / `then` / `else` 已按“不改变生成类型形状”的边界落地:
- 只允许 object 节点上的 object-typed inline schema - 只允许 object 节点上的 object-typed inline schema
- `if` 必填,且必须至少伴随 `then``else` 之一 - `if` 必填,且必须至少伴随 `then``else` 之一
@ -95,4 +98,4 @@
1. 提交并推送当前 PR `#262` follow-up 修复后,重新抓取一次 PR review确认 outside-diff comment 与 open thread 是否都已收口 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` 盘点下一批候选关键字 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`,优先筛选下一个仍不改变生成类型形状、且不需要属性合并或联合分支生成的共享关键字

View File

@ -106,6 +106,32 @@
### 下一步 ### 下一步
1. 评估 `oneOf` / `anyOf` 是否值得继续沿用 object-focused 子集;若仍会造成生成形状漂移,就直接跳过 1. 跳过 `oneOf` / `anyOf`,优先筛选下一个仍不改变生成类型形状、且不需要属性合并或联合分支生成的共享关键字
2. 若继续扩共享关键字,先在 Runtime / Generator / Tooling 三端同时定义一致边界,再进入实现 2. 若继续扩共享关键字,先在 Runtime / Generator / Tooling 三端同时定义一致边界,再进入实现
3. 继续把 active 入口保持精简,只记录当前恢复点、验证与下一步 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 同步收口

View File

@ -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 未声明的额外同级字段继续存在 - `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 语义把每个条目叠加到当前对象上,不做属性合并,也不改变生成类型形状 - `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 语义,允许对象保留未在条件块中声明的额外同级字段 - `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` 条目不会把新字段并回父对象。 `allOf` 的最小可工作示例如下。关键点是:字段形状先在父对象 `properties` 中声明,再用 `allOf` 叠加 `required` 或更细的字段约束;`allOf` 条目不会把新字段并回父对象。

View File

@ -1095,6 +1095,13 @@ function unquoteScalar(value) {
*/ */
function parseSchemaNode(rawNode, displayPath) { function parseSchemaNode(rawNode, displayPath) {
const value = rawNode && typeof rawNode === "object" ? rawNode : {}; 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 type = typeof value.type === "string" ? value.type : "object";
const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath); const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath); const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath);
@ -1253,6 +1260,25 @@ function parseSchemaNode(rawNode, displayPath) {
}, value.const, displayPath), value.enum, 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<string, unknown>} 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 * Parse one optional `not` sub-schema and keep path formatting aligned with
* the runtime/generator diagnostics. * the runtime/generator diagnostics.

View File

@ -178,6 +178,33 @@ test("parseSchemaContent should preserve empty-string const raw and display meta
assert.equal(schema.properties.name.constDisplayValue, "\"\""); 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", () => { test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {