diff --git a/.codex/skills/gframework-pr-review/SKILL.md b/.codex/skills/gframework-pr-review/SKILL.md index 748c7d08..a2f6d8f2 100644 --- a/.codex/skills/gframework-pr-review/SKILL.md +++ b/.codex/skills/gframework-pr-review/SKILL.md @@ -17,6 +17,7 @@ Shortcut: `$gframework-pr-review` - locate the PR for the current branch through the GitHub PR API - fetch PR metadata, issue comments, reviews, and review comments through the GitHub API - extract `Summary by CodeRabbit`、GitHub Actions bot comments such as `MegaLinter analysis: Success with warnings`、and CTRF test reports from issue comments + - parse the latest CodeRabbit review body itself, including folded sections such as `🧹 Nitpick comments (N)` and the overall AI-agent prompt - fetch the latest head commit review threads from the GitHub PR API - prefer unresolved review threads on the latest head commit over older summary-only signals - extract failed checks, MegaLinter detailed issues, and test-report signals such as `Failed Tests` or `No failed tests in this run` @@ -39,6 +40,7 @@ The script should produce: - PR metadata: number, title, state, branch, URL - CodeRabbit summary block from issue comments when available +- Folded latest-review sections such as `Nitpick comments (N)` when CodeRabbit puts them in the review body instead of issue comments - Parsed latest head-review threads, with unresolved threads clearly separated - Latest head commit review metadata and review threads - Unresolved latest-commit review threads after reply-thread folding @@ -54,6 +56,7 @@ The script should produce: - Prefer GitHub API results over PR HTML. The PR HTML page is now a fallback/debugging source, not the primary source of truth. - If the summary block and the latest head review threads disagree, trust the latest unresolved head-review threads and treat older summary findings as stale until re-verified locally. - Treat GitHub Actions comments with `Success with warnings` as actionable review input when they include concrete linter diagnostics such as `MegaLinter` detailed issues; do not skip them just because the parent check is green. +- Do not assume all CodeRabbit findings live in issue comments. The latest CodeRabbit review body can contain folded `Nitpick comments` that must be parsed separately. ## Example Triggers diff --git a/.codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py b/.codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py index cabd9ebe..017f2424 100644 --- a/.codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py +++ b/.codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py @@ -210,19 +210,39 @@ def parse_actionable_comments(actionable_block: str) -> dict[str, Any]: comment_count_match = re.search(r"Actionable comments posted:\s*(\d+)", actionable_block) count = int(comment_count_match.group(1)) if comment_count_match else 0 - comments: list[dict[str, str]] = [] primary_block = actionable_block.split( "
\n🤖 Prompt for all review comments with AI agents", 1, )[0] + comments = parse_comment_cards(primary_block) + + prompt_match = re.search( + r"🤖 Prompt for all review comments with AI agents\s*```(.*?)```", + actionable_block, + re.S, + ) + + return { + "count": count or len(comments), + "comments": comments, + "all_comments_prompt": prompt_match.group(1).strip() if prompt_match else "", + "raw": actionable_block.strip(), + } + + +def parse_comment_cards(comment_block: str) -> list[dict[str, str]]: + comments: list[dict[str, str]] = [] pattern = re.compile( r"" - r"((?:[^<\n]+/)*[^<\n]+\.(?:cs|md|csproj|yaml|yml|json|txt|props|targets)|AGENTS\.md|CLAUDE\.md|README\.md|\.gitignore)" + # CodeRabbit can fold cards for source, docs, scripts, and repo config files. + # Keep the matcher path-like, but do not hardcode a tiny extension allow-list + # or we will silently drop valid findings such as .py skill files. + r"((?:[^<\n]+/)*[^<\n/]+(?:\.[A-Za-z0-9._-]+)+|AGENTS\.md|CLAUDE\.md|README\.md|\.gitignore)" r" \((\d+)\)
\s*(.*?)\s*(?:(?:
)|(?:))", re.S, ) - for path, _, body in pattern.findall(primary_block): + for path, _, body in pattern.findall(comment_block): finding_match = re.search(r"`([^`]+)`: \*\*(.*?)\*\*", body, re.S) prompt_match = re.search(r"🤖 Prompt for AI Agents\s*```(.*?)```", body, re.S) suggestion_match = re.search(r"✏️ 建议文案调整\s*```diff(.*?)```", body, re.S) @@ -243,17 +263,67 @@ def parse_actionable_comments(actionable_block: str) -> dict[str, Any]: } ) - prompt_match = re.search( - r"🤖 Prompt for all review comments with AI agents\s*```(.*?)```", - actionable_block, + return comments + + +def normalize_review_body_for_parsing(review_body: str) -> str: + # CodeRabbit sometimes wraps structured HTML sections in markdown blockquotes, + # such as the CAUTION block used for outside-diff comments. Remove the quote + # prefixes for parsing while leaving the original raw body unchanged for output. + return re.sub(r"(?m)^>\s?", "", review_body) + + +def find_section_block_end(review_body: str, block_start: int) -> int: + depth = 1 + for tag_match in re.finditer(r"
|
", review_body[block_start:]): + tag = tag_match.group(0) + if tag == "
": + depth += 1 + else: + depth -= 1 + if depth == 0: + return block_start + tag_match.start() + + return len(review_body) + + +def parse_review_comment_group(review_body: str, section_name: str) -> dict[str, Any]: + section_match = re.search( + rf"[^<]*{re.escape(section_name)} \((?P\d+)\)
\s*", + review_body, re.S, ) + if section_match is None: + return {"count": 0, "comments": [], "raw": ""} + block_end = find_section_block_end(review_body, section_match.end()) + comment_block = review_body[section_match.end() : block_end].strip() + comment_block = re.sub(r"\s*
\s*$", "", comment_block, flags=re.S) return { - "count": count, - "comments": comments, + "count": int(section_match.group("count")), + "comments": parse_comment_cards(comment_block), + "raw": comment_block, + } + + +def parse_latest_review_body(review_body: str) -> dict[str, Any]: + normalized_review_body = normalize_review_body_for_parsing(review_body) + actionable_count_match = re.search(r"\*\*Actionable comments posted:\s*(\d+)\*\*", normalized_review_body) + prompt_match = re.search( + r"🤖 Prompt for all review comments with AI agents\s*```(.*?)```", + normalized_review_body, + re.S, + ) + outside_diff_group = parse_review_comment_group(normalized_review_body, "Outside diff range comments") + nitpick_group = parse_review_comment_group(normalized_review_body, "Nitpick comments") + return { + "actionable_count": int(actionable_count_match.group(1)) if actionable_count_match else 0, + "outside_diff_count": outside_diff_group["count"], + "outside_diff_comments": outside_diff_group["comments"], + "nitpick_count": nitpick_group["count"], + "nitpick_comments": nitpick_group["comments"], "all_comments_prompt": prompt_match.group(1).strip() if prompt_match else "", - "raw": actionable_block.strip(), + "raw": review_body.strip(), } @@ -548,12 +618,39 @@ def build_result(pr_number: int, branch: str) -> dict[str, Any]: warnings.append("MegaLinter report block was not found in issue comments.") latest_commit_review: dict[str, Any] = {} + coderabbit_review: dict[str, Any] = {} try: latest_commit_review = fetch_latest_commit_review(pr_number) + latest_review = latest_commit_review.get("latest_review", {}) + latest_review_body = str(latest_review.get("body") or "") + if latest_review.get("user") == CODERABBIT_LOGIN and latest_review_body: + coderabbit_review = parse_latest_review_body(latest_review_body) + outside_diff_count = int(coderabbit_review.get("outside_diff_count") or 0) + parsed_outside_diff_count = len(coderabbit_review.get("outside_diff_comments", [])) + nitpick_count = int(coderabbit_review.get("nitpick_count") or 0) + parsed_nitpick_count = len(coderabbit_review.get("nitpick_comments", [])) + if "Outside diff range comments" in latest_review_body and not parsed_outside_diff_count: + warnings.append("CodeRabbit outside-diff comments block could not be parsed from the latest review body.") + elif outside_diff_count and parsed_outside_diff_count != outside_diff_count: + warnings.append( + "CodeRabbit outside-diff comments were only partially parsed from the latest review body: " + f"declared={outside_diff_count}, parsed={parsed_outside_diff_count}." + ) + if "Nitpick comments" in latest_review_body and not parsed_nitpick_count: + warnings.append("CodeRabbit nitpick comments block could not be parsed from the latest review body.") + elif nitpick_count and parsed_nitpick_count != nitpick_count: + warnings.append( + "CodeRabbit nitpick comments were only partially parsed from the latest review body: " + f"declared={nitpick_count}, parsed={parsed_nitpick_count}." + ) except Exception as error: # noqa: BLE001 warnings.append(f"Latest commit review comments could not be fetched: {error}") - if not actionable_block and not latest_commit_review.get("threads"): + if ( + not actionable_block + and not latest_commit_review.get("threads") + and not coderabbit_review.get("nitpick_comments") + ): warnings.append("CodeRabbit actionable comments block was not found in issue comments.") return { @@ -571,6 +668,7 @@ def build_result(pr_number: int, branch: str) -> dict[str, Any]: "raw": summary_block, }, "coderabbit_comments": parse_actionable_comments(actionable_block) if actionable_block else {}, + "coderabbit_review": coderabbit_review, "latest_commit_review": latest_commit_review, "megalinter_report": parse_megalinter_comment(megalinter_block) if megalinter_block else {}, "test_reports": [parse_test_report(block) for block in test_blocks], @@ -594,15 +692,42 @@ def format_text(result: dict[str, Any]) -> str: lines.append(f" Explanation: {check['explanation']}") lines.append(f" Resolution: {check['resolution']}") - comments = result.get("coderabbit_comments", {}).get("comments", []) + coderabbit_comments = result.get("coderabbit_comments", {}) + review_feedback = result.get("coderabbit_review", {}) + comments = coderabbit_comments.get("comments", []) + actionable_count = review_feedback.get("actionable_count") or coderabbit_comments.get("count") or len(comments) lines.append("") - lines.append(f"CodeRabbit actionable comments: {len(comments)}") + lines.append(f"CodeRabbit actionable comments: {actionable_count}") for comment in comments: lines.append(f"- {comment['path']} {comment['range']}".rstrip()) if comment["title"]: lines.append(f" Title: {comment['title']}") if comment["description"]: lines.append(f" Description: {comment['description']}") + if actionable_count and not comments: + lines.append(" Details: see latest-commit review threads below.") + + outside_diff_comments = review_feedback.get("outside_diff_comments", []) + outside_diff_count = review_feedback.get("outside_diff_count") or len(outside_diff_comments) + lines.append("") + lines.append(f"CodeRabbit outside-diff comments: {outside_diff_count} declared, {len(outside_diff_comments)} parsed") + for comment in outside_diff_comments: + lines.append(f"- {comment['path']} {comment['range']}".rstrip()) + if comment["title"]: + lines.append(f" Title: {comment['title']}") + if comment["description"]: + lines.append(f" Description: {comment['description']}") + + nitpick_comments = review_feedback.get("nitpick_comments", []) + nitpick_count = review_feedback.get("nitpick_count") or len(nitpick_comments) + lines.append("") + lines.append(f"CodeRabbit nitpick comments: {nitpick_count} declared, {len(nitpick_comments)} parsed") + for comment in nitpick_comments: + lines.append(f"- {comment['path']} {comment['range']}".rstrip()) + if comment["title"]: + lines.append(f" Title: {comment['title']}") + if comment["description"]: + lines.append(f" Description: {comment['description']}") latest_commit_review = result.get("latest_commit_review", {}) latest_commit = latest_commit_review.get("latest_commit", {}) diff --git a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md index e6d7b883..333f7249 100644 --- a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -17,3 +17,4 @@ GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_012 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_013 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index d4103b9c..0ca5ea00 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -9,7 +9,8 @@ namespace GFramework.Game.SourceGenerators.Config; /// 当前共享子集也会把 multipleOfuniqueItems、 /// contains / minContains / maxContains、 /// minPropertiesmaxPropertiesdependentRequired、 -/// dependentSchemasallOf 与稳定字符串 format 子集写入生成代码文档, +/// dependentSchemasallOf、object-focused if / then / else +/// 与稳定字符串 format 子集写入生成代码文档, /// 让消费者能直接在强类型 API 上看到运行时生效且不改变生成类型形状的约束。 /// [Generator] @@ -169,6 +170,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return SchemaParseResult.FromDiagnostic(allOfDiagnostic!); } + if (!TryValidateConditionalSchemasMetadataRecursively( + file.Path, + "", + root, + out var conditionalDiagnostic)) + { + return SchemaParseResult.FromDiagnostic(conditionalDiagnostic!); + } + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); var rootObject = ParseObjectSpec( file.Path, @@ -690,7 +700,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 - /// 该遍历覆盖对象属性、dependentSchemas / allOf / not 子 schema、 + /// 该遍历覆盖对象属性、dependentSchemas / allOf / + /// if / then / else / not 子 schema、 /// 数组 itemscontains, /// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。 /// @@ -795,6 +806,45 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + if (string.Equals(schemaType, "object", StringComparison.Ordinal)) + { + if (element.TryGetProperty("if", out var ifElement) && + ifElement.ValueKind == JsonValueKind.Object && + !TryTraverseSchemaRecursively( + filePath, + BuildConditionalSchemaPath(displayPath, "if"), + ifElement, + nodeValidator, + out diagnostic)) + { + return false; + } + + if (element.TryGetProperty("then", out var thenElement) && + thenElement.ValueKind == JsonValueKind.Object && + !TryTraverseSchemaRecursively( + filePath, + BuildConditionalSchemaPath(displayPath, "then"), + thenElement, + nodeValidator, + out diagnostic)) + { + return false; + } + + if (element.TryGetProperty("else", out var elseElement) && + elseElement.ValueKind == JsonValueKind.Object && + !TryTraverseSchemaRecursively( + filePath, + BuildConditionalSchemaPath(displayPath, "else"), + elseElement, + nodeValidator, + out diagnostic)) + { + return false; + } + } + if (element.TryGetProperty("not", out var notElement) && notElement.ValueKind == JsonValueKind.Object && !TryTraverseSchemaRecursively( @@ -850,6 +900,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return $"{displayPath}[allOf[{allOfIndex}]]"; } + /// + /// 为 object-focused 条件分支生成与运行时一致的逻辑路径。 + /// + /// 父对象路径。 + /// 条件关键字名称。 + /// 格式化后的条件分支路径。 + private static string BuildConditionalSchemaPath(string displayPath, string keywordName) + { + return $"{displayPath}[{keywordName}]"; + } + /// /// 递归验证 schema 树中的对象级 dependentRequired 元数据。 /// 该遍历会覆盖根节点、dependentSchemas / not 子 schema、 @@ -1336,6 +1397,333 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 验证当前 schema 节点是否以运行时支持的方式声明了 object-focused if / then / else。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 当前节点声明的 schema 类型。 + /// 失败时返回的诊断。 + /// 当前节点上的条件元数据是否有效。 + private static bool TryValidateConditionalSchemasDeclaration( + string filePath, + string displayPath, + JsonElement element, + string? schemaType, + out Diagnostic? diagnostic) + { + diagnostic = null; + var hasIf = element.TryGetProperty("if", out _); + var hasThen = element.TryGetProperty("then", out _); + var hasElse = element.TryGetProperty("else", out _); + if (!hasIf && !hasThen && !hasElse) + { + return true; + } + + if (!string.Equals(schemaType, "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Only object schemas can declare 'if', 'then', or 'else'."); + return false; + } + + return TryValidateConditionalSchemasMetadata(filePath, displayPath, element, out diagnostic); + } + + /// + /// 递归验证 schema 树中的 object-focused if / then / else 元数据。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树的条件元数据是否有效。 + private static bool TryValidateConditionalSchemasMetadataRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, schemaType) => + { + return TryValidateConditionalSchemasDeclaration( + currentFilePath, + currentDisplayPath, + currentElement, + schemaType, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + + /// + /// 验证单个对象 schema 节点上的 object-focused 条件元数据。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前对象 schema 节点。 + /// 失败时返回的诊断。 + /// 当前对象上的条件元数据是否有效。 + private static bool TryValidateConditionalSchemasMetadata( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + var hasIf = element.TryGetProperty("if", out var ifElement); + var hasThen = element.TryGetProperty("then", out var thenElement); + var hasElse = element.TryGetProperty("else", out var elseElement); + if (!hasIf && !hasThen && !hasElse) + { + return true; + } + + if (!hasIf) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Object schemas using 'then' or 'else' must also declare 'if'."); + return false; + } + + if (!hasThen && !hasElse) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Object schemas using 'if' must also declare at least one of 'then' or 'else'."); + return false; + } + + if (!element.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Object schemas using 'if/then/else' must also declare an object-valued 'properties' map."); + return false; + } + + var declaredProperties = new HashSet( + propertiesElement + .EnumerateObject() + .Select(static property => property.Name), + StringComparer.Ordinal); + + if (!TryValidateConditionalSchemaBranch( + filePath, + displayPath, + ifElement, + "if", + declaredProperties, + out diagnostic)) + { + return false; + } + + if (hasThen && + !TryValidateConditionalSchemaBranch( + filePath, + displayPath, + thenElement, + "then", + declaredProperties, + out diagnostic)) + { + return false; + } + + return !hasElse || + TryValidateConditionalSchemaBranch( + filePath, + displayPath, + elseElement, + "else", + declaredProperties, + out diagnostic); + } + + /// + /// 验证单个 object-focused 条件分支的类型与父对象字段引用范围。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// 当前条件分支 schema。 + /// 条件关键字名称。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前条件分支是否有效。 + private static bool TryValidateConditionalSchemaBranch( + string filePath, + string displayPath, + JsonElement schemaElement, + string keywordName, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + var branchPath = BuildConditionalSchemaPath(displayPath, keywordName); + if (schemaElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + branchPath, + $"The '{keywordName}' value must be an object-valued schema."); + return false; + } + + if (!schemaElement.TryGetProperty("type", out var typeElement) || + typeElement.ValueKind != JsonValueKind.String || + !string.Equals(typeElement.GetString(), "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + branchPath, + $"The '{keywordName}' schema must declare an object-typed schema."); + return false; + } + + return TryValidateObjectFocusedSchemaTargets( + filePath, + branchPath, + keywordName, + schemaElement, + declaredProperties, + out diagnostic); + } + + /// + /// 验证 object-focused 内联 schema 只引用父对象已声明的同级字段。 + /// + /// Schema 文件路径。 + /// 当前内联 schema 路径。 + /// 用于诊断文本的条目标签。 + /// 当前内联 schema。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前内联 schema 是否有效。 + private static bool TryValidateObjectFocusedSchemaTargets( + string filePath, + string displayPath, + string entryLabel, + JsonElement schemaElement, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (schemaElement.TryGetProperty("properties", out var propertiesElement)) + { + if (propertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"The '{entryLabel}' schema must declare 'properties' as an object-valued map."); + return false; + } + + foreach (var property in propertiesElement.EnumerateObject()) + { + if (declaredProperties.Contains(property.Name)) + { + continue; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"The '{entryLabel}' schema declares property '{property.Name}', but that property is not declared in the parent object schema."); + return false; + } + } + + if (!schemaElement.TryGetProperty("required", out var requiredElement)) + { + return true; + } + + if (requiredElement.ValueKind != JsonValueKind.Array) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"The '{entryLabel}' schema must declare 'required' as an array of parent property names."); + return false; + } + + foreach (var requiredProperty in requiredElement.EnumerateArray()) + { + if (requiredProperty.ValueKind != JsonValueKind.String) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"The '{entryLabel}' schema must declare 'required' entries as parent property-name strings."); + return false; + } + + var requiredPropertyName = requiredProperty.GetString(); + if (string.IsNullOrWhiteSpace(requiredPropertyName)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"The '{entryLabel}' schema cannot declare blank property names in 'required'."); + return false; + } + + if (declaredProperties.Contains(requiredPropertyName!)) + { + continue; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidConditionalSchemaMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"The '{entryLabel}' schema requires property '{requiredPropertyName}', but that property is not declared in the parent object schema."); + return false; + } + + return true; + } + /// /// 验证单个 allOf 条目只约束父对象已声明的同级字段。 /// @@ -3540,7 +3928,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains、 - /// 对象属性数量 / dependent* / allOf 约束整理成 XML 文档可读字符串。 + /// 对象属性数量 / dependent* / allOf / if-then-else 约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -3693,6 +4081,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { parts.Add($"allOf = {allOfDocumentation}"); } + + var conditionalDocumentation = TryBuildConditionalDocumentation(element); + if (conditionalDocumentation is not null) + { + parts.Add($"if/then/else = {conditionalDocumentation}"); + } } return parts.Count > 0 ? string.Join(", ", parts) : null; @@ -3807,6 +4201,103 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator : null; } + /// + /// 将对象 if / then / else 条件约束整理成 XML 文档可读字符串。 + /// + /// 对象 schema 节点。 + /// 格式化后的条件约束说明。 + private static string? TryBuildConditionalDocumentation(JsonElement element) + { + if (!element.TryGetProperty("if", out var ifElement) || + ifElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + var ifSummary = TryBuildConditionalBranchSummary(ifElement); + if (ifSummary is null) + { + return null; + } + + var parts = new List { $"if {ifSummary}" }; + if (element.TryGetProperty("then", out var thenElement) && + thenElement.ValueKind == JsonValueKind.Object) + { + var thenSummary = TryBuildConditionalBranchSummary(thenElement); + if (thenSummary is not null) + { + parts.Add($"then {thenSummary}"); + } + } + + if (element.TryGetProperty("else", out var elseElement) && + elseElement.ValueKind == JsonValueKind.Object) + { + var elseSummary = TryBuildConditionalBranchSummary(elseElement); + if (elseSummary is not null) + { + parts.Add($"else {elseSummary}"); + } + } + + return parts.Count > 1 + ? string.Join("; ", parts) + : null; + } + + /// + /// 汇总条件分支的对象级约束与子属性约束,避免生成文档只保留笼统的 object 描述。 + /// + /// 条件分支 schema。 + /// 格式化后的条件分支摘要。 + private static string? TryBuildConditionalBranchSummary(JsonElement branchElement) + { + var branchSummary = TryBuildInlineSchemaSummary(branchElement, includeRequiredProperties: true); + if (branchSummary is null) + { + return null; + } + + var propertiesSummary = TryBuildInlineObjectPropertiesSummary(branchElement); + return propertiesSummary is null + ? branchSummary + : $"{branchSummary}; properties = {propertiesSummary}"; + } + + /// + /// 汇总对象 properties 内每个字段的紧凑约束,补足条件分支文档里的触发条件细节。 + /// + /// 对象 schema 节点。 + /// 格式化后的子属性约束摘要。 + private static string? TryBuildInlineObjectPropertiesSummary(JsonElement schemaElement) + { + if (!schemaElement.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + var parts = new List(); + foreach (var property in propertiesElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + var propertySummary = TryBuildInlineSchemaSummary(property.Value); + if (propertySummary is not null) + { + parts.Add($"{property.Name}: {propertySummary}"); + } + } + + return parts.Count == 0 + ? null + : $"{{ {string.Join("; ", parts)} }}"; + } + /// /// 将数组 contains 子 schema 整理成 XML 文档可读字符串。 /// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。 diff --git a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index c39d275f..e2071f54 100644 --- a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -140,4 +140,15 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 对象节点的 if/then/else 条件元数据无效。 + /// + public static readonly DiagnosticDescriptor InvalidConditionalSchemaMetadata = new( + "GF_ConfigSchema_013", + "Config schema uses invalid if/then/else metadata", + "Property '{1}' in schema file '{0}' uses invalid 'if/then/else' metadata: {2}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderIfThenElseTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderIfThenElseTests.cs new file mode 100644 index 00000000..c73c3a56 --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderIfThenElseTests.cs @@ -0,0 +1,570 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器对 object-focused if / then / else 约束的运行时行为。 +/// +[TestFixture] +public sealed class YamlConfigLoaderIfThenElseTests +{ + private const string DefaultRewardPropertiesJson = """ + { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + } + """; + + private const string DefaultConditionalJson = """ + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "then": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { + "type": "integer", + "minimum": 2 + } + } + }, + "else": { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { + "type": "integer", + "minimum": 1 + } + } + } + """; + + private string? _rootPath; + + /// + /// 为每个用例创建隔离的临时目录,避免不同条件分支场景互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理当前测试创建的目录,避免本地临时文件堆积。 + /// + [TearDown] + public void TearDown() + { + if (!string.IsNullOrEmpty(_rootPath) && + Directory.Exists(_rootPath)) + { + try + { + Directory.Delete(_rootPath, true); + } + catch (IOException) + { + // Ignore cleanup failures in test teardown + } + catch (UnauthorizedAccessException) + { + // Ignore cleanup failures in test teardown + } + } + } + + /// + /// 验证 if 命中而 then 约束未满足时,运行时会拒绝加载。 + /// + [Test] + public void LoadAsync_Should_Throw_When_If_Matches_But_Then_Is_Not_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultConditionalJson)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward")); + Assert.That(exception.Message, Does.Contain("'then'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 if 命中且 then 约束满足时可以正常加载。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_If_Matches_And_Then_Is_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultConditionalJson)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("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)); + Assert.That(reward.Bonus, Is.EqualTo(0)); + }); + } + + /// + /// 验证 if 未命中而 else 约束未满足时,运行时会拒绝加载。 + /// + [Test] + public void LoadAsync_Should_Throw_When_If_Does_Not_Match_But_Else_Is_Not_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: sword + itemCount: 1 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultConditionalJson)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward")); + Assert.That(exception.Message, Does.Contain("'else'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 if 未命中且 else 约束满足时可以正常加载。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_If_Does_Not_Match_And_Else_Is_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: sword + bonus: 2 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultConditionalJson)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + var reward = table.Get(1).Reward; + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(reward.ItemId, Is.EqualTo("sword")); + Assert.That(reward.ItemCount, Is.EqualTo(0)); + Assert.That(reward.Bonus, Is.EqualTo(2)); + }); + } + + /// + /// 验证非对象字段声明 if 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_NonObject_Schema_Declares_If() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + tag: elite + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "tag"], + "properties": { + "id": { "type": "integer" }, + "tag": { + "type": "string", + "if": { + "type": "object", + "properties": {} + }, + "then": { + "type": "object", + "properties": {} + } + } + } + } + """); + + ArgumentNullException.ThrowIfNull(_rootPath); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(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("tag")); + Assert.That(exception.Message, Does.Contain("can only declare 'if' on object schemas")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证缺少 if 却声明 then 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Then_Is_Declared_Without_If() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 2 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + "then": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(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 'if' when using 'then' or 'else'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证缺少 if 却声明 else 时,会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Else_Is_Declared_Without_If() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + bonus: 1 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + "else": { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "type": "integer" } + } + } + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(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 'if' when using 'then' or 'else'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证条件分支不能要求父对象未声明的字段。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Conditional_Schema_Requires_Undeclared_Parent_Property() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + itemCount: 2 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + "if": { + "type": "object", + "required": ["bonusCount"], + "properties": { + "itemId": { "type": "string" } + } + }, + "then": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + } + } + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(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[if]")); + Assert.That(exception.Message, Does.Contain("requires property 'bonusCount'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 写入测试配置文件,复用统一的测试文件创建逻辑。 + /// + /// 配置文件相对路径。 + /// 配置文件内容。 + 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)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, content); + } + + /// + /// 写入测试 schema 文件,复用统一的测试文件创建逻辑。 + /// + /// schema 相对路径。 + /// schema JSON 内容。 + private void CreateSchemaFile(string relativePath, string content) + { + CreateConfigFile(relativePath, content); + } + + /// + /// 构建带有指定奖励内容的怪物配置 YAML 文本。 + /// + /// 奖励对象的 YAML 片段。 + /// 完整的怪物配置 YAML 文本。 + private static string BuildMonsterConfigYaml(string rewardYaml) + { + return $$""" + id: 1 + reward: + {{IndentLines(rewardYaml, 2)}} + """; + } + + /// + /// 构建带有指定奖励属性和条件约束的怪物 schema JSON。 + /// + /// 奖励对象的 properties JSON 片段。 + /// 条件约束的 JSON 条目片段。 + /// 完整的 schema JSON 文本。 + private static string BuildMonsterSchema( + string rewardPropertiesJson, + string conditionalJson) + { + return $$""" + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": {{rewardPropertiesJson}}, + {{conditionalJson}} + } + } + } + """; + } + + /// + /// 为多行文本的每一行添加指定数量的空格缩进。 + /// + /// 原始文本。 + /// 缩进空格数。 + /// 添加缩进后的文本。 + private static string IndentLines(string text, int indentLevel) + { + var indentation = new string(' ', indentLevel); + var lines = text + .Trim() + .Split('\n', StringSplitOptions.None) + .Select(static line => line.TrimEnd('\r')); + + return string.Join( + Environment.NewLine, + lines.Select(line => $"{indentation}{line}")); + } + + /// + /// 创建用于 object-focused 条件分支场景的加载器。 + /// + /// 已注册测试表与 schema 路径的加载器。 + private YamlConfigLoader CreateMonsterRewardLoader() + { + ArgumentNullException.ThrowIfNull(_rootPath); + + return new YamlConfigLoader(_rootPath) + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + } + + /// + /// 创建新的配置注册表,确保每个用例从干净状态开始。 + /// + /// 空的配置注册表。 + private static ConfigRegistry CreateRegistry() + { + return new ConfigRegistry(); + } + + /// + /// 用于 object-focused 条件分支回归测试的最小配置类型。 + /// + private sealed class MonsterConditionalConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置奖励对象。 + /// + public ConditionalRewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 表示条件分支回归测试中的奖励节点。 + /// + private sealed class ConditionalRewardConfigStub + { + /// + /// 获取或设置掉落物 ID。 + /// + public string ItemId { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物数量。 + /// + public int ItemCount { get; set; } + + /// + /// 获取或设置额外奖励值。 + /// + public int Bonus { get; set; } + } + + /// + /// 用于非对象条件关键字场景回归测试的最小配置类型。 + /// + private sealed class MonsterTagConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置标签。 + /// + public string Tag { get; set; } = string.Empty; + } +} diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs index 145ab2f9..4358d850 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs @@ -5,7 +5,8 @@ namespace GFramework.Game.Config; /// /// 承载对象级 schema 关键字的解析与元数据校验逻辑。 /// 该 partial 将 minPropertiesmaxProperties、 -/// dependentRequireddependentSchemasallOf +/// dependentRequireddependentSchemasallOf +/// 与 object-focused if / then / else /// 从主校验文件中拆出,降低超大文件继续堆叠对象关键字时的维护成本。 /// internal static partial class YamlConfigSchemaValidator @@ -41,6 +42,7 @@ internal static partial class YamlConfigSchemaValidator var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties); var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); var allOfSchemas = ParseAllOfConstraints(tableName, schemaPath, propertyPath, element, properties); + var conditionalSchemas = ParseConditionalSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) { @@ -54,9 +56,15 @@ internal static partial class YamlConfigSchemaValidator } return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null && - allOfSchemas is null + allOfSchemas is null && conditionalSchemas is null ? null - : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas, allOfSchemas); + : new YamlConfigObjectConstraints( + minProperties, + maxProperties, + dependentRequired, + dependentSchemas, + allOfSchemas, + conditionalSchemas); } /// @@ -296,12 +304,12 @@ internal static partial class YamlConfigSchemaValidator } var allOfSchemaPath = BuildNestedSchemaPath(propertyPath, $"allOf[{allOfIndex.ToString(CultureInfo.InvariantCulture)}]"); - ValidateAllOfSchemaTargetsAgainstParentObject( + ValidateInlineObjectSchemaTargetsAgainstParentObject( tableName, schemaPath, propertyPath, allOfSchemaPath, - allOfIndex + 1, + $"Entry #{(allOfIndex + 1).ToString(CultureInfo.InvariantCulture)} in 'allOf'", allOfSchemaElement, properties); var allOfSchemaNode = ParseNode( @@ -329,39 +337,176 @@ internal static partial class YamlConfigSchemaValidator } /// - /// 验证 allOf 条目只约束父对象已经声明过的同级字段。 - /// 当前 object-focused 语义不会把条目里的属性并回父对象形状,因此这里要提前拒绝 + /// 解析对象节点声明的 object-focused if / then / else 条件约束。 + /// 当前共享子集要求三段内联 schema 都保持 object-typed focused block 语义, + /// 既允许根据 sibling 值切换约束分支,又避免把条件 schema 扩展成新的生成类型形状。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 父对象已声明的属性集合。 + /// 归一化后的条件约束;未声明时返回空。 + private static YamlConfigConditionalSchemas? ParseConditionalSchemasConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + var hasIf = element.TryGetProperty("if", out var ifElement); + var hasThen = element.TryGetProperty("then", out var thenElement); + var hasElse = element.TryGetProperty("else", out var elseElement); + if (!hasIf && !hasThen && !hasElse) + { + return null; + } + + if (!hasIf) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'if' when using 'then' or 'else'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (!hasThen && !hasElse) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare at least one of 'then' or 'else' when using 'if'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var ifSchemaPath = BuildNestedSchemaPath(propertyPath, "if"); + var ifSchemaNode = ParseConditionalObjectSchema( + tableName, + schemaPath, + propertyPath, + ifSchemaPath, + "if", + ifElement, + properties); + + var thenSchemaNode = hasThen + ? ParseConditionalObjectSchema( + tableName, + schemaPath, + propertyPath, + BuildNestedSchemaPath(propertyPath, "then"), + "then", + thenElement, + properties) + : null; + var elseSchemaNode = hasElse + ? ParseConditionalObjectSchema( + tableName, + schemaPath, + propertyPath, + BuildNestedSchemaPath(propertyPath, "else"), + "else", + elseElement, + properties) + : null; + + return new YamlConfigConditionalSchemas(ifSchemaNode, thenSchemaNode, elseSchemaNode); + } + + /// + /// 解析单个条件分支的 object-focused 内联 schema。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 父对象路径。 + /// 当前条件分支路径。 + /// 条件关键字名称。 + /// 当前条件分支 schema。 + /// 父对象已声明的属性集合。 + /// 解析后的 object-typed schema。 + private static YamlConfigSchemaNode ParseConditionalObjectSchema( + string tableName, + string schemaPath, + string propertyPath, + string conditionalSchemaPath, + string keywordName, + JsonElement conditionalSchemaElement, + IReadOnlyDictionary properties) + { + if (conditionalSchemaElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(conditionalSchemaPath)} in schema file '{schemaPath}' must declare '{keywordName}' as an object-valued schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(conditionalSchemaPath)); + } + + ValidateInlineObjectSchemaTargetsAgainstParentObject( + tableName, + schemaPath, + propertyPath, + conditionalSchemaPath, + $"'{keywordName}'", + conditionalSchemaElement, + properties); + + var conditionalSchemaNode = ParseNode( + tableName, + schemaPath, + conditionalSchemaPath, + conditionalSchemaElement); + if (conditionalSchemaNode.NodeType == YamlConfigSchemaPropertyType.Object) + { + return conditionalSchemaNode; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare an object-typed '{keywordName}' schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(conditionalSchemaPath)); + } + + /// + /// 验证 object-focused 内联 schema 只约束父对象已经声明过的同级字段。 + /// 当前 shared subset 不会把 focused block 内字段并回父对象形状,因此这里会提前拒绝 /// “在 focused block 里引入父对象未声明字段”的不可满足 schema。 /// /// 所属配置表名称。 /// Schema 文件路径。 /// 父对象路径。 - /// 当前 allOf 条目路径。 - /// 从 1 开始的 allOf 条目编号。 - /// 当前 allOf 条目。 + /// 当前内联 schema 路径。 + /// 用于诊断文本的条目标签。 + /// 当前内联 schema。 /// 父对象已声明的属性集合。 - private static void ValidateAllOfSchemaTargetsAgainstParentObject( + private static void ValidateInlineObjectSchemaTargetsAgainstParentObject( string tableName, string schemaPath, string propertyPath, - string allOfSchemaPath, - int allOfEntryNumber, - JsonElement allOfSchemaElement, + string inlineSchemaPath, + string entryLabel, + JsonElement inlineSchemaElement, IReadOnlyDictionary properties) { - if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement)) + if (inlineSchemaElement.TryGetProperty("properties", out var inlinePropertiesElement)) { - if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) + if (inlinePropertiesElement.ValueKind != JsonValueKind.Object) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'properties' as an object-valued map.", + $"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'properties' as an object-valued map.", schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); + displayPath: GetDiagnosticPath(inlineSchemaPath)); } - foreach (var property in allOfPropertiesElement.EnumerateObject()) + foreach (var property in inlinePropertiesElement.EnumerateObject()) { if (properties.ContainsKey(property.Name)) { @@ -371,37 +516,37 @@ internal static partial class YamlConfigSchemaValidator throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' declares property '{property.Name}', but that property is not declared in the parent object schema.", + $"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' declares property '{property.Name}', but that property is not declared in the parent object schema.", schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); + displayPath: GetDiagnosticPath(inlineSchemaPath)); } } - if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement)) + if (!inlineSchemaElement.TryGetProperty("required", out var inlineRequiredElement)) { return; } - if (allOfRequiredElement.ValueKind != JsonValueKind.Array) + if (inlineRequiredElement.ValueKind != JsonValueKind.Array) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' as an array of property names.", + $"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' as an array of property names.", schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); + displayPath: GetDiagnosticPath(inlineSchemaPath)); } - foreach (var requiredProperty in allOfRequiredElement.EnumerateArray()) + foreach (var requiredProperty in inlineRequiredElement.EnumerateArray()) { if (requiredProperty.ValueKind != JsonValueKind.String) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' entries as property-name strings.", + $"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' entries as property-name strings.", schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); + displayPath: GetDiagnosticPath(inlineSchemaPath)); } var requiredPropertyName = requiredProperty.GetString(); @@ -410,9 +555,9 @@ internal static partial class YamlConfigSchemaValidator throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank property names in 'required'.", + $"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank property names in 'required'.", schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); + displayPath: GetDiagnosticPath(inlineSchemaPath)); } if (properties.ContainsKey(requiredPropertyName)) @@ -423,9 +568,9 @@ internal static partial class YamlConfigSchemaValidator throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' requires property '{requiredPropertyName}', but that property is not declared in the parent object schema.", + $"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' requires property '{requiredPropertyName}', but that property is not declared in the parent object schema.", schemaPath: schemaPath, - displayPath: GetDiagnosticPath(allOfSchemaPath)); + displayPath: GetDiagnosticPath(inlineSchemaPath)); } } diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index dabff389..e33e86de 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -11,9 +11,10 @@ namespace GFramework.Game.Config; /// 当前共享子集额外支持 multipleOfuniqueItems、 /// contains / minContains / maxContains、 /// minPropertiesmaxPropertiesdependentRequired、 -/// dependentSchemasallOf +/// dependentSchemasallOf、object-focused if / then / else /// 与稳定字符串 format 子集,让数值步进、数组去重、数组匹配计数、 -/// 对象属性数量、对象内字段依赖、条件对象子 schema 与对象组合约束在运行时与生成器 / 工具侧保持一致。 +/// 对象属性数量、对象内字段依赖、条件对象子 schema、对象组合约束与条件分支约束 +/// 在运行时与生成器 / 工具侧保持一致。 /// internal static partial class YamlConfigSchemaValidator { @@ -326,12 +327,12 @@ internal static partial class YamlConfigSchemaValidator var typeName = typeElement.GetString() ?? string.Empty; var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); if (!string.Equals(typeName, "object", StringComparison.Ordinal) && - element.TryGetProperty("allOf", out _)) + TryGetObjectOnlyKeywordName(element) is { } objectOnlyKeywordName) { throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.SchemaUnsupported, tableName, - $"Property '{propertyPath}' in schema file '{schemaPath}' can only declare 'allOf' on object schemas.", + $"Property '{propertyPath}' in schema file '{schemaPath}' can only declare '{objectOnlyKeywordName}' on object schemas.", schemaPath: schemaPath, displayPath: GetDiagnosticPath(propertyPath)); } @@ -826,7 +827,8 @@ internal static partial class YamlConfigSchemaValidator /// 校验对象节点声明的数量约束与条件对象约束。 /// 该阶段除了检查 minProperties / maxProperties,还会复用同一份 sibling 集合处理 /// dependentRequired,并在 dependentSchemas 命中时以 focused constraint block 语义 - /// 对整个 做额外试匹配。 + /// 对整个 做额外试匹配;若声明了 object-focused + /// if / then / else,则先按同样的 focused matcher 判断条件分支,再只对命中的分支追加约束。 /// /// 所属配置表名称。 /// YAML 文件路径。 @@ -835,7 +837,7 @@ internal static partial class YamlConfigSchemaValidator /// 当前对象已出现的属性集合。 /// 对象 schema 节点。 /// - /// 可选的跨表引用收集器;当 dependentSchemasallOf 命中且匹配成功时, + /// 可选的跨表引用收集器;当 dependentSchemasallOf 或条件分支命中且匹配成功时, /// 只会回写对应内联分支新增的引用。 /// private static void ValidateObjectConstraints( @@ -964,40 +966,99 @@ internal static partial class YamlConfigSchemaValidator } } - if (constraints.AllOfSchemas is null || - constraints.AllOfSchemas.Count == 0) + if (constraints.AllOfSchemas is not null && + constraints.AllOfSchemas.Count > 0) + { + for (var index = 0; index < constraints.AllOfSchemas.Count; index++) + { + var allOfSchema = constraints.AllOfSchemas[index]; + // allOf follows the same focused constraint block semantics as dependentSchemas: + // the inline schema may validate a subset of the current object without forcing + // unrelated sibling fields to be restated. + if (TryMatchSchemaNode( + tableName, + yamlPath, + displayPath, + mappingNode, + allOfSchema, + references, + allowUnknownObjectProperties: true)) + { + continue; + } + + var allOfEntryNumber = index + 1; + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"{subject} in config file '{yamlPath}' must satisfy all 'allOf' schemas, but entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} did not match.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + detail: + $"allOf entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} must match the current object."); + } + } + + var conditionalSchemas = constraints.ConditionalSchemas; + if (conditionalSchemas is null) { return; } - for (var index = 0; index < constraints.AllOfSchemas.Count; index++) + // if/then/else follows the same object-focused matcher contract as dependentSchemas/allOf: + // condition evaluation can inspect a subset of the current object without forcing unrelated + // sibling fields to be re-declared inside the branch schema. + var ifMatched = TryMatchSchemaNode( + tableName, + yamlPath, + displayPath, + mappingNode, + conditionalSchemas.IfSchema, + references, + allowUnknownObjectProperties: true); + if (ifMatched && + conditionalSchemas.ThenSchema is not null && + !TryMatchSchemaNode( + tableName, + yamlPath, + displayPath, + mappingNode, + conditionalSchemas.ThenSchema, + references, + allowUnknownObjectProperties: true)) { - var allOfSchema = constraints.AllOfSchemas[index]; - // allOf follows the same focused constraint block semantics as dependentSchemas: - // the inline schema may validate a subset of the current object without forcing - // unrelated sibling fields to be restated. - if (TryMatchSchemaNode( - tableName, - yamlPath, - displayPath, - mappingNode, - allOfSchema, - references, - allowUnknownObjectProperties: true)) - { - continue; - } - - var allOfEntryNumber = index + 1; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, - $"{subject} in config file '{yamlPath}' must satisfy all 'allOf' schemas, but entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} did not match.", + $"{subject} in config file '{yamlPath}' must satisfy the 'then' schema because the inline 'if' condition matched.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), detail: - $"allOf entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} must match the current object."); + "Conditional schema: the current object matched the inline 'if' schema, so it must also satisfy the corresponding 'then' schema."); + } + + if (!ifMatched && + conditionalSchemas.ElseSchema is not null && + !TryMatchSchemaNode( + tableName, + yamlPath, + displayPath, + mappingNode, + conditionalSchemas.ElseSchema, + references, + allowUnknownObjectProperties: true)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"{subject} in config file '{yamlPath}' must satisfy the 'else' schema because the inline 'if' condition did not match.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + detail: + "Conditional schema: the current object did not match the inline 'if' schema, so it must satisfy the corresponding 'else' schema."); } } @@ -3379,6 +3440,21 @@ internal static partial class YamlConfigSchemaValidator CollectReferencedTableNames(allOfSchemaNode, referencedTableNames); } } + + var conditionalSchemas = node.ObjectConstraints?.ConditionalSchemas; + if (conditionalSchemas is not null) + { + CollectReferencedTableNames(conditionalSchemas.IfSchema, referencedTableNames); + if (conditionalSchemas.ThenSchema is not null) + { + CollectReferencedTableNames(conditionalSchemas.ThenSchema, referencedTableNames); + } + + if (conditionalSchemas.ElseSchema is not null) + { + CollectReferencedTableNames(conditionalSchemas.ElseSchema, referencedTableNames); + } + } } /// @@ -3517,6 +3593,33 @@ internal static partial class YamlConfigSchemaValidator return string.IsNullOrWhiteSpace(parentPath) ? propertyName : $"{parentPath}.{propertyName}"; } + /// + /// 返回当前节点上声明的“仅对象可用”关键字名称。 + /// + /// Schema 节点。 + /// 命中的关键字名称;未命中时返回空。 + private static string? TryGetObjectOnlyKeywordName(JsonElement element) + { + if (element.TryGetProperty("allOf", out _)) + { + return "allOf"; + } + + if (element.TryGetProperty("if", out _)) + { + return "if"; + } + + if (element.TryGetProperty("then", out _)) + { + return "then"; + } + + return element.TryGetProperty("else", out _) + ? "else" + : null; + } + /// /// 判断当前标量是否应按字符串处理。 /// 这里显式排除 YAML 的数字、布尔和 null 标签,避免未加引号的值被当成字符串混入运行时。 @@ -3985,18 +4088,21 @@ internal sealed class YamlConfigObjectConstraints /// 对象内字段依赖约束。 /// 对象内条件 schema 约束。 /// 对象内组合 schema 约束。 + /// 对象内条件分支约束。 public YamlConfigObjectConstraints( int? minProperties, int? maxProperties, IReadOnlyDictionary>? dependentRequired, IReadOnlyDictionary? dependentSchemas, - IReadOnlyList? allOfSchemas) + IReadOnlyList? allOfSchemas, + YamlConfigConditionalSchemas? conditionalSchemas) { MinProperties = minProperties; MaxProperties = maxProperties; DependentRequired = dependentRequired; DependentSchemas = dependentSchemas; AllOfSchemas = allOfSchemas; + ConditionalSchemas = conditionalSchemas; } /// @@ -4026,6 +4132,52 @@ internal sealed class YamlConfigObjectConstraints /// 每个条目都表示“当前对象还必须额外满足的 focused constraint block”。 /// public IReadOnlyList? AllOfSchemas { get; } + + /// + /// 获取对象内 object-focused if / then / else 条件约束。 + /// 该模型会先用 if 试匹配当前对象,再只对命中的分支叠加 focused constraint block。 + /// + public YamlConfigConditionalSchemas? ConditionalSchemas { get; } +} + +/// +/// 表示一个对象节点上声明的 object-focused if / then / else 条件约束。 +/// 三个分支都共享父对象已声明字段集合,不会把分支 schema 扩展成新的生成类型形状。 +/// +internal sealed class YamlConfigConditionalSchemas +{ + /// + /// 初始化条件分支约束模型。 + /// + /// 条件判断 schema。 + /// 条件命中时需要满足的 schema。 + /// 条件未命中时需要满足的 schema。 + public YamlConfigConditionalSchemas( + YamlConfigSchemaNode ifSchema, + YamlConfigSchemaNode? thenSchema, + YamlConfigSchemaNode? elseSchema) + { + ArgumentNullException.ThrowIfNull(ifSchema); + + IfSchema = ifSchema; + ThenSchema = thenSchema; + ElseSchema = elseSchema; + } + + /// + /// 获取条件判断 schema。 + /// + public YamlConfigSchemaNode IfSchema { get; } + + /// + /// 获取条件命中时需要满足的 schema。 + /// + public YamlConfigSchemaNode? ThenSchema { get; } + + /// + /// 获取条件未命中时需要满足的 schema。 + /// + public YamlConfigSchemaNode? ElseSchema { get; } } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 88442e3c..2da0ffa9 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -1267,6 +1267,491 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证 object-focused if / then / else 会写入生成 XML 文档。 + /// + [Test] + public void Run_Should_Write_IfThenElse_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" }, + "bonus": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "then": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + }, + "else": { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "type": "integer" } + } + } + } + } + } + """; + + 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: if/then/else = if object; properties = { itemId: string (const = \"potion\") }; " + + "then object (required = [itemCount]); properties = { itemCount: integer }; " + + "else object (required = [bonus]); properties = { bonus: integer }.")); + } + + /// + /// 验证缺少 if 时生成器会拒绝孤立的 then。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Then_Is_Declared_Without_If() + { + 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": { + "itemCount": { "type": "integer" } + }, + "then": { + "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_013")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must also declare 'if'")); + }); + } + + /// + /// 验证缺少 if 时生成器也会拒绝孤立的 else。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Else_Is_Declared_Without_If() + { + 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": { + "bonus": { "type": "integer" } + }, + "else": { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "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_013")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must also declare 'if'")); + }); + } + + /// + /// 验证只声明 if 而没有分支时,生成器会给出对齐运行时的诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_If_Is_Declared_Without_Then_Or_Else() + { + 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" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + } + } + } + } + """; + + 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_013")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must also declare at least one of 'then' or 'else'")); + }); + } + + /// + /// 验证条件分支不是 object schema 时,诊断路径会定位到具体分支而不是父对象。 + /// + [Test] + public void Run_Should_Report_Diagnostic_With_Branch_Path_When_Then_Schema_Is_Not_Object() + { + 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" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "then": [] + } + } + } + """; + + 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_013")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[then]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must be an object-valued schema")); + }); + } + + /// + /// 验证条件分支不能引用父对象未声明的字段。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Conditional_Schema_Requires_Undeclared_Parent_Property() + { + 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" } + }, + "if": { + "type": "object", + "required": ["bonusCount"], + "properties": { + "itemId": { "type": "string" } + } + }, + "then": { + "type": "object", + "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_013")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[if]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("bonusCount")); + }); + } + + /// + /// 验证 then 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_With_Runtime_Aligned_Path_When_Then_Inner_Schema_Is_Invalid() + { + 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" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "then": { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "format": "uuid" + } + } + } + } + } + } + """; + + 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_009")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[then].itemCount")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'.")); + }); + } + + /// + /// 验证 else 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_With_Runtime_Aligned_Path_When_Else_Inner_Schema_Is_Invalid() + { + 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" }, + "bonus": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "else": { + "type": "object", + "properties": { + "bonus": { + "type": "integer", + "format": "uuid" + } + } + } + } + } + } + """; + + 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_009")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[else].bonus")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare '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 8fb93b44..c45850ab 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` - - 当前产出:运行时拒绝相关约束违规值,VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf` 当前收敛为 object-focused constraint block,不做属性合并 + - 已补齐:`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,不做属性合并 - [x] 评估可选只读索引能力 - 目标:为高频查询字段提供比 `All()` 线性扫描更强的读取体验 @@ -75,7 +75,7 @@ 1. 用 `GeneratedConfigCatalog` 继续补齐启动与诊断辅助 2. 补一条比 `Architecture.OnInitialize()` 更正式的模块化接入建议 - 当前状态:第 1 项和第 2 项已完成,`allOf` 也已补齐;下一步转到仍不改变生成形状的组合关键字评估(优先看 `if` / `then` / `else`),或继续推进 VS Code 复杂编辑体验 + 当前状态:第 1 项和第 2 项已完成,`allOf` 与 object-focused `if` / `then` / `else` 也已补齐;下一步转到下一批仍不改变生成形状的组合关键字评估,或继续推进 VS Code 复杂编辑体验 ## 完成标准 @@ -86,27 +86,27 @@ ## 下次恢复点 -- 在当前稳定 `format` 子集(`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)以及 object-focused `allOf` 之后,转到下一批仍不改变生成类型形状的关键字评估;仍然不要先回工具 UI +- 在当前稳定 `format` 子集(`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、object-focused `allOf` 与 object-focused `if` / `then` / `else` 之后,转到下一批仍不改变生成类型形状的关键字评估;仍然不要先回工具 UI - 恢复时优先检查: - `GFramework.Game/Config/YamlConfigSchemaValidator.cs` - - `GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs` + - `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` - `tools/gframework-config-tool/src/configValidation.js` - `tools/gframework-config-tool/src/extension.js` - `docs/zh-CN/game/config-system.md` ### 恢复块 -- 恢复点编号:`AI-FIRST-CONFIG-RP-002` +- 恢复点编号:`AI-FIRST-CONFIG-RP-003` - 当前阶段:`C# Runtime + Source Generator + Consumer DX` - 已知风险: - - 语义一致性风险:`if` / `then` / `else` 在 Runtime / Generator / Tooling 三端语义不一致的风险,需要先验证是否能在不引入生成类型形状漂移的前提下落地 + - 复杂关键字形状风险:下一批候选关键字若像标准 `oneOf` / `anyOf` 那样影响对象分支形状,可能破坏当前生成契约 - 工具链非阻塞风险:将 VS Code 功能标为非阻塞后,可能导致 C# 主线补齐新关键字时缺少工具侧同步验证 - - 复杂关键字回退风险:`allOf` 已收敛为 object-focused constraint block,未来新增组合关键字时需明确是否同样限制范围 + - 组合关键字范围风险:`allOf` 与 `if` / `then` / `else` 已收敛为 object-focused constraint block,未来新增组合关键字时需明确是否同样限制范围 - 最近验证: - - 时间:2026-04-17 - - 内容:截至该日期的历史跟踪与执行 trace 已归档到主题内归档目录 + - 时间:2026-04-20 + - 内容:`bun run test`、`SchemaConfigGeneratorTests`、`YamlConfigLoaderIfThenElseTests` - 结果:通过 - 下一步: 1. 检查 `YamlConfigSchemaValidator.cs`、`SchemaConfigGenerator.cs`、`configValidation.js` 中当前已支持的关键字列表 - 2. 评估 `if` / `then` / `else` 是否能在三端保持一致语义且不改变生成类型形状 - 3. 若结论否定,选择下一批共享解释关键字而不是先回工具 UI \ No newline at end of file + 2. 评估 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集 + 3. 若结论否定,选择下一批共享解释关键字而不是先回工具 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 cc319107..aca43ecc 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 @@ -7,19 +7,24 @@ ## 当前恢复点 -- 恢复点编号:`AI-FIRST-CONFIG-RP-002` +- 恢复点编号:`AI-FIRST-CONFIG-RP-003` - 当前阶段:`C# Runtime + Source Generator + Consumer DX` - 当前焦点: - - 在当前稳定 `format` 子集与 object-focused `allOf` 之后,继续评估仍不改变生成类型形状的下一批组合关键字 - - 优先考察 `if` / `then` / `else` 是否能在 Runtime / Generator / Tooling 三端保持一致语义 + - 已完成 object-focused `if` / `then` / `else`,继续评估下一批仍不改变生成类型形状的共享关键字 + - 已完成 PR #262 的 CodeRabbit follow-up,补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests + - 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema - 继续把 VS Code 工具能力视为非阻塞项,不让复杂 UI 编辑器需求反过来拖慢 C# 主线 ### 已知风险 -- 语义一致性风险:`if` / `then` / `else` 在 Runtime / Generator / Tooling 三端语义不一致的风险 - - 缓解措施:先验证是否能在不引入生成类型形状漂移的前提下落地,若否则选择下一批共享解释关键字 +- 组合关键字扩展风险:下一批候选关键字可能像标准 `oneOf` / `anyOf` 一样更容易引入生成类型形状漂移 + - 缓解措施:延续 object-focused / focused matcher 约束,只接受三端都能稳定解释且不需要属性合并的子集 - 工具链验证风险:VS Code 与 CI / 发布管道验证覆盖不足 - - 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过 + - 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过,并记录 JS 测试与构建验证 +- PR review 信号漂移风险:CodeRabbit 可能把建议折叠在 latest review body,而不是 issue comments + - 缓解措施:`gframework-pr-review` 现已同时解析 latest review body,并输出 declared / parsed 数量以便快速识别解析缺口 +- PR follow-up 残留风险:PR `#262` 最新 review thread 仍有少量 open comments,且 nitpick body 解析仍存在 declared / parsed 缺口 + - 缓解措施:先以 latest unresolved thread 为准逐条本地核验;已确认并补齐运行时诊断路径与 `else without if` 回归测试,skill 现已补齐 `.py` nitpick 与 outside-diff comment 解析,剩余项只需等待本地修复推送后再复抓确认 - 非阻塞项回退风险:将 VS Code 功能标为非阻塞但导致主线回退的风险 - 缓解措施:C# 主线补齐新关键字时仍需在 `configValidation.js` 与 `extension.js` 中同步落地,只是不让复杂表单控件阻塞发布 @@ -31,7 +36,33 @@ - `enum`、`const`、`not`、`pattern` - `format` 稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid` - `minItems`、`maxItems`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`uniqueItems` - - `minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf` + - `minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else` +- `if` / `then` / `else` 已按“不改变生成类型形状”的边界落地: + - 只允许 object 节点上的 object-typed inline schema + - `if` 必填,且必须至少伴随 `then` 或 `else` 之一 + - 分支只能引用父对象已声明字段,不做属性合并 + - 条件匹配沿用 `dependentSchemas` / `allOf` 的 focused matcher 语义 +- 相关实现与验证入口: + - Runtime:`GFramework.Game/Config/YamlConfigSchemaValidator.cs`、`GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs` + - Generator:`GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` + - Tooling:`tools/gframework-config-tool/src/configValidation.js`、`tools/gframework-config-tool/src/extension.js` + - Tests:`GFramework.Game.Tests/Config/YamlConfigLoaderIfThenElseTests.cs`、`GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs`、`tools/gframework-config-tool/test/configValidation.test.js` +- PR review follow-up 收口: + - `gframework-pr-review` 现已解析 latest CodeRabbit review body 中 folded `Nitpick comments` + - text 输出会显示 `CodeRabbit nitpick comments: X declared, Y parsed`,避免再次静默遗漏 + - 已按 5 条 nitpick 更新 VS Code tool hints、shared validation helper,以及对称分支测试覆盖 +- PR `#262` 最新 follow-up: + - 最新抓取结果显示 latest review body 里有 2 条 nitpick 与 1 条 outside-diff actionable comment + - `SchemaConfigGenerator` 的分支级诊断定位已在当前分支,无需重复修改 + - `YamlConfigSchemaValidator` 已补齐 `conditionalSchemaPath` 诊断路径,避免 `reward[then]` / `reward[else]` 坏形状误报到父路径 + - `YamlConfigLoaderIfThenElseTests` 已新增运行时 `else` 缺失 `if` 回归,避免 Runtime / Generator 覆盖漂移 + - active trace 已将重复的 `### 验证` 标题改为专用 PR follow-up 标题,消除 `MD024` + - `gframework-pr-review` 现已在 latest review body 中同时解析 `Outside diff range comments` 与 `Nitpick comments` + - `parse_comment_cards` 已不再遗漏 `.codex/.../*.py` 这类 skill 文件评论卡片 + - `tools/gframework-config-tool/src/configValidation.js` 已按 outside-diff 建议收紧条件分支坏形状拒绝规则,并补齐 JS 回归测试 +- 分支同步状态: + - `feat/ai-first-config` 已 rebase 到 `origin/feat/ai-first-config` + - 当前已解决“ahead / behind 同时存在”的分支差异,不再 behind 远端 - 当前最细粒度的下一阶段 backlog 保留在独立文件: - `ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md` @@ -53,9 +84,15 @@ - `2026-04-17` 之前的详细实现记录与定向验证命令已归档到历史 tracking / trace - active 跟踪文件只保留当前恢复点、当前状态和下一步,不再重复堆积已完成阶段的完整历史 +- `2026-04-20` 当前恢复点验证: + - `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 262 --format json`:通过(`CodeRabbit outside-diff comments: 1 declared, 1 parsed`,`CodeRabbit nitpick comments: 2 declared, 2 parsed`) + - `bun run test`(`tools/gframework-config-tool`):通过(122 tests;包含条件分支坏形状回归) + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"`:通过 + - `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderIfThenElseTests"`:通过(8 tests;新增 `else without if` 运行时回归) + - `dotnet build GFramework.sln -c Release`:通过(存在仓库既有 analyzer warning,无新增错误) ## 下一步 -1. 先检查 `GFramework.Game/Config/YamlConfigSchemaValidator.cs`、`GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs`、`tools/gframework-config-tool/src/configValidation.js` -2. 评估 `if` / `then` / `else` 是否能在不引入生成类型形状漂移的前提下落地 -3. 若结论是否定,再选择下一批仍能共享解释的关键字,而不是先回到工具 UI 深挖 \ No newline at end of file +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 子集;若仍会引入生成类型形状漂移,就直接跳过 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 167219db..e59b4c93 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 @@ -32,4 +32,80 @@ 1. 从 `ai-first-config-system-csharp-experience-next.md` 读取当前 backlog,而不是继续翻已完成历史 2. 先判断 `if` / `then` / `else` 是否满足“三端一致且不改变生成形状”的前提 -3. 若不满足,直接回退到下一批收益更明确的共享关键字评估 \ No newline at end of file +3. 若不满足,直接回退到下一批收益更明确的共享关键字评估 + +## 2026-04-20 + +### 阶段:object-focused `if` / `then` / `else` 收口(AI-FIRST-CONFIG-RP-003) + +- 已在 Runtime、Source Generator 与 VS Code Tooling 三端落地 object-focused `if` / `then` / `else` +- 本轮采用的约束边界: + - 仅允许 object 节点上的 object-typed inline schema + - `if` 必填,且必须至少存在 `then` 或 `else` 之一 + - `then` / `else` 只能约束父对象已声明字段,不做属性合并 + - 条件匹配沿用 `dependentSchemas` / `allOf` 的 focused matcher 语义,允许未在条件块中声明的额外同级字段继续存在 +- 生成器新增 `GF_ConfigSchema_013`,在生成阶段提前拒绝坏形状的条件元数据,并把条件摘要写入 XML 文档 +- VS Code 工具同步补齐 schema 解析、校验消息、本地化文本与表单 hint 元数据显示 + +### 验证 + +- 2026-04-20:`bun run test`(`tools/gframework-config-tool`) + - 结果:通过 +- 2026-04-20:`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"` + - 结果:通过 +- 2026-04-20:`dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderIfThenElseTests"` + - 结果:通过 + - 备注:修正断言路径后,运行时诊断显示路径与 `reward[if]` / `reward[then]` 的约定保持一致 +- 2026-04-20:`dotnet build GFramework.sln -c Release` + - 结果:通过 + - 备注:解决方案构建成功;输出包含仓库既有 analyzer warning,但无新增错误 + +### 阶段:PR #262 review follow-up 与分支同步 + +- 已使用 `gframework-pr-review` 复核 PR #262,并确认 latest CodeRabbit review body 的第一行下方存在 folded `🧹 Nitpick comments (5)` +- 已修复 `fetch_current_pr_review.py` 的 follow-up 盲区: + - 不再只依赖 issue comments,而会解析 latest review body 中的 folded nitpick cards + - `parse_comment_cards` 现已覆盖 `.js/.ts` 等工具文件路径 + - text 输出会同时显示 declared / parsed 数量,避免 future drift 时静默少报 +- 已按 5 条 nitpick 收口代码: + - VS Code tooling 的 `ifElse` hint 现会显示 `condition` + - `extension.js` 已抽出可复用的 `InlineObjectSchemaHint` typedef + - `configValidation.js` 已抽取共享 target reference 校验 helper + - Source Generator tests 已补齐对称分支覆盖 + - Runtime test cleanup 已从 `catch (Exception)` 收窄到 IO / 权限异常 +- 已处理本地分支与远端分支差异: + - 本地 `feat/ai-first-config` 已 rebase 到 `origin/feat/ai-first-config` + - rebase 过程中 Git 跳过了远端已具备的 commit `76488dc` + - 当前分支已不再 behind 远端,仅保留本地领先提交 + +### PR `#262` review follow-up 验证 + +- 2026-04-20:`python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py` + - 结果:通过 + - 备注:输出 `CodeRabbit actionable comments: 2`、`CodeRabbit nitpick comments: 2 declared, 1 parsed`,并暴露剩余 review follow-up +- 2026-04-20:skill parser follow-up + - 结果:已补齐 + - 备注:`gframework-pr-review` 现可解析 latest review body 中的 `Outside diff range comments`,并且不再遗漏 `.codex/.../*.py` nitpick cards +- 2026-04-20:`python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 262 --format json` + - 结果:通过 + - 备注:输出 `CodeRabbit outside-diff comments: 1 declared, 1 parsed`、`CodeRabbit nitpick comments: 2 declared, 2 parsed`,parser warning 清零 +- 2026-04-20:运行时条件分支 follow-up + - 结果:已补齐 + - 备注:`YamlConfigSchemaValidator` 现对非 object 的 `if` / `then` / `else` 使用分支级诊断路径;运行时测试新增 `else` 缺失 `if` 回归 +- 2026-04-20:`bun run test`(`tools/gframework-config-tool`) + - 结果:通过(122 tests) + - 备注:新增条件分支坏形状回归后,tooling 现在会拒绝缺失 `type: "object"`、坏形状 `properties`、坏形状 `required` 与空白 required 成员 +- 2026-04-20:`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"` + - 结果:通过(46 tests) +- 2026-04-20:`dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderIfThenElseTests"` + - 结果:通过(8 tests) + - 备注:新增 `LoadAsync_Should_Throw_When_Else_Is_Declared_Without_If` 后,运行时回归覆盖保持对称 +- 2026-04-20:`dotnet build GFramework.sln -c Release` + - 结果:通过(历史记录) + - 备注:存在仓库既有 analyzer warning,但无新增错误;本轮只需重新验证受影响测试切片 + +### 下一步 + +1. 评估 `oneOf` / `anyOf` 是否值得继续沿用 object-focused 子集;若仍会造成生成形状漂移,就直接跳过 +2. 若继续扩共享关键字,先在 Runtime / Generator / Tooling 三端同时定义一致边界,再进入实现 +3. 继续把 active 入口保持精简,只记录当前恢复点、验证与下一步 diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 1879854c..14a6e727 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - 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`、`dependentRequired`、`dependentSchemas`、`allOf` +- 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`、`dependentSchemas`、`allOf`、`if` / `then` / `else` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -794,6 +794,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `dependentRequired`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只表达“当对象内某个字段出现时,还必须同时声明哪些同级字段”,不会改变生成类型形状 - `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 语义,允许对象保留未在条件块中声明的额外同级字段 `allOf` 的最小可工作示例如下。关键点是:字段形状先在父对象 `properties` 中声明,再用 `allOf` 叠加 `required` 或更细的字段约束;`allOf` 条目不会把新字段并回父对象。 @@ -835,6 +836,60 @@ reward: 兼容性说明:如果你以前按标准 JSON Schema `allOf` 的直觉,把新字段只写进 `allOf` 条目的 `properties` 或 `required`,当前实现不会做属性合并,这类 schema 现在会在加载 / 生成 / 工具解析阶段直接被拒绝。请先把字段提升到父对象的 `properties`,再在 `allOf` 里补充 required 或约束。 +`if` / `then` / `else` 的最小可工作示例如下。关键点是:先在父对象声明完整字段形状,再用 `if` 判断当前对象的某个分支条件,并在 `then` / `else` 中追加 focused constraint block。 + +```json +{ + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["item", "gold"] }, + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "gold": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "kind": { "const": "item" } + } + }, + "then": { + "type": "object", + "required": ["itemId", "itemCount"], + "properties": { + "itemCount": { + "type": "integer", + "minimum": 1 + } + } + }, + "else": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { + "type": "integer", + "minimum": 1 + } + } + } + } + } +} +``` + +```yaml +reward: + kind: item + itemId: potion + itemCount: 3 +``` + +兼容性说明:当前实现不会按标准 JSON Schema 的广义组合语义去推导新对象形状。如果你把新字段只写进 `then` / `else` 的 `properties` 或 `required`,这类 schema 会在加载 / 生成 / 工具解析阶段直接被拒绝。请先把字段提升到父对象的 `properties`,再把条件分支当作“命中后附加约束”来使用。 + 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 加载失败时,`YamlConfigLoader` 会抛出 `ConfigLoadException`。你可以通过 `exception.Diagnostic` 读取稳定字段,而不必解析消息文本: @@ -930,7 +985,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 +- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf / if / then / else` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index ce6426e2..6c7b193c 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1129,6 +1129,11 @@ function parseSchemaNode(rawNode, displayPath) { throw new Error(`Only object schemas can declare 'allOf' at '${displayPath}'.`); } + if ((value.if !== undefined || value.then !== undefined || value.else !== undefined) && + type !== "object") { + throw new Error(`Only object schemas can declare 'if', 'then', or 'else' at '${displayPath}'.`); + } + if (type === "object") { const required = Array.isArray(value.required) ? value.required.filter((item) => typeof item === "string") @@ -1140,6 +1145,7 @@ function parseSchemaNode(rawNode, displayPath) { const dependentRequired = parseDependentRequiredMetadata(value.dependentRequired, displayPath, properties); const dependentSchemas = parseDependentSchemasMetadata(value.dependentSchemas, displayPath, properties); const allOf = parseAllOfSchemaNodes(value.allOf, displayPath, properties); + const conditionalSchemas = parseConditionalSchemaMetadata(value.if, value.then, value.else, displayPath, properties); return applyEnumMetadata(applyConstMetadata({ type: "object", @@ -1151,6 +1157,9 @@ function parseSchemaNode(rawNode, displayPath) { dependentRequired, dependentSchemas, allOf, + ifSchema: conditionalSchemas ? conditionalSchemas.ifSchema : undefined, + thenSchema: conditionalSchemas ? conditionalSchemas.thenSchema : undefined, + elseSchema: conditionalSchemas ? conditionalSchemas.elseSchema : undefined, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, @@ -1421,6 +1430,87 @@ function parseAllOfSchemaNodes(rawAllOf, displayPath, properties) { : undefined; } +/** + * Parse one object-level `if/then/else` group and keep it aligned with the + * runtime's object-focused conditional constraint contract. + * + * @param {unknown} rawIf Raw `if` node. + * @param {unknown} rawThen Raw `then` node. + * @param {unknown} rawElse Raw `else` node. + * @param {string} displayPath Parent schema path. + * @param {Record} properties Declared parent properties. + * @returns {{ifSchema: SchemaNode, thenSchema?: SchemaNode, elseSchema?: SchemaNode} | undefined} Normalized conditional schema group. + */ +function parseConditionalSchemaMetadata(rawIf, rawThen, rawElse, displayPath, properties) { + const hasIf = rawIf !== undefined; + const hasThen = rawThen !== undefined; + const hasElse = rawElse !== undefined; + if (!hasIf && !hasThen && !hasElse) { + return undefined; + } + + if (!hasIf) { + throw new Error(`Schema property '${displayPath}' must declare 'if' when using 'then' or 'else'.`); + } + + if (!hasThen && !hasElse) { + throw new Error(`Schema property '${displayPath}' must declare at least one of 'then' or 'else' when using 'if'.`); + } + + const ifSchema = parseConditionalObjectSchema(rawIf, displayPath, "if", properties); + const conditionalSchemas = {ifSchema}; + + if (hasThen) { + conditionalSchemas.thenSchema = parseConditionalObjectSchema(rawThen, displayPath, "then", properties); + } + + if (hasElse) { + conditionalSchemas.elseSchema = parseConditionalObjectSchema(rawElse, displayPath, "else", properties); + } + + return conditionalSchemas; +} + +/** + * Parse one object-focused conditional branch schema. + * + * @param {unknown} rawSchema Raw branch schema. + * @param {string} displayPath Parent schema path. + * @param {"if" | "then" | "else"} keywordName Branch keyword. + * @param {Record} properties Declared parent properties. + * @returns {SchemaNode} Parsed object-typed branch schema. + */ +function parseConditionalObjectSchema(rawSchema, displayPath, keywordName, properties) { + if (!rawSchema || typeof rawSchema !== "object" || Array.isArray(rawSchema)) { + throw new Error(`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema.`); + } + + if (rawSchema.type !== "object") { + throw new Error(`Schema property '${displayPath}' must declare an object-typed '${keywordName}' schema.`); + } + + validateConditionalSchemaTargets(rawSchema, displayPath, keywordName, properties); + const conditionalSchema = parseSchemaNode(rawSchema, `${displayPath}[${keywordName}]`); + if (conditionalSchema.type !== "object") { + throw new Error(`Schema property '${displayPath}' must declare an object-typed '${keywordName}' schema.`); + } + + return conditionalSchema; +} + +/** + * Ensure one object-focused conditional branch only constrains properties that + * the parent object schema already declared. + * + * @param {unknown} rawSchema Raw branch schema. + * @param {string} displayPath Parent schema path. + * @param {"if" | "then" | "else"} keywordName Branch keyword. + * @param {Record} properties Declared parent properties. + */ +function validateConditionalSchemaTargets(rawSchema, displayPath, keywordName, properties) { + validateDeclaredTargetReferences(rawSchema, displayPath, `'${keywordName}'`, properties); +} + /** * Ensure one object-focused `allOf` entry only constrains properties that the * parent object schema already declared. @@ -1431,31 +1521,60 @@ function parseAllOfSchemaNodes(rawAllOf, displayPath, properties) { * @param {Record} properties Declared parent properties. */ function validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, properties) { - if (!rawAllOfSchema || typeof rawAllOfSchema !== "object" || Array.isArray(rawAllOfSchema)) { + validateDeclaredTargetReferences(rawAllOfSchema, displayPath, `'allOf' entry #${index + 1}`, properties); +} + +/** + * Ensure one focused object schema only references properties that the parent + * object schema already declared. + * + * @param {unknown} rawSchema Raw object-focused schema. + * @param {string} displayPath Parent schema path. + * @param {string} contextLabel Human-readable constraint origin label. + * @param {Record} properties Declared parent properties. + */ +function validateDeclaredTargetReferences(rawSchema, displayPath, contextLabel, properties) { + if (!rawSchema || typeof rawSchema !== "object" || Array.isArray(rawSchema)) { return; } - if (rawAllOfSchema.properties && - typeof rawAllOfSchema.properties === "object" && - !Array.isArray(rawAllOfSchema.properties)) { - for (const propertyName of Object.keys(rawAllOfSchema.properties)) { + if (rawSchema.properties !== undefined) { + if (!rawSchema.properties || + typeof rawSchema.properties !== "object" || + Array.isArray(rawSchema.properties)) { + throw new Error( + `Schema property '${displayPath}' must declare 'properties' in ${contextLabel} as an object-valued map.`); + } + + for (const propertyName of Object.keys(rawSchema.properties)) { if (Object.prototype.hasOwnProperty.call(properties, propertyName)) { continue; } throw new Error( - `Schema property '${displayPath}' declares property '${propertyName}' in 'allOf' entry #${index + 1}, ` + + `Schema property '${displayPath}' declares property '${propertyName}' in ${contextLabel}, ` + "but that property is not declared in the parent object schema."); } } - if (!Array.isArray(rawAllOfSchema.required)) { + if (rawSchema.required === undefined) { return; } - for (const requiredProperty of rawAllOfSchema.required) { - if (typeof requiredProperty !== "string" || requiredProperty.trim().length === 0) { - continue; + if (!Array.isArray(rawSchema.required)) { + throw new Error( + `Schema property '${displayPath}' must declare 'required' in ${contextLabel} as an array of property names.`); + } + + for (const requiredProperty of rawSchema.required) { + if (typeof requiredProperty !== "string") { + throw new Error( + `Schema property '${displayPath}' must declare 'required' entries in ${contextLabel} as property-name strings.`); + } + + if (requiredProperty.trim().length === 0) { + throw new Error( + `Schema property '${displayPath}' cannot declare blank property names in 'required' for ${contextLabel}.`); } if (Object.prototype.hasOwnProperty.call(properties, requiredProperty)) { @@ -1463,7 +1582,7 @@ function validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, propertie } throw new Error( - `Schema property '${displayPath}' requires property '${requiredProperty}' in 'allOf' entry #${index + 1}, ` + + `Schema property '${displayPath}' requires property '${requiredProperty}' in ${contextLabel}, ` + "but that property is not declared in the parent object schema."); } } @@ -1887,6 +2006,48 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca } } + const ifMatched = schemaNode.ifSchema + ? matchesSchemaNode(schemaNode.ifSchema, yamlNode, true) + : false; + if (ifMatched && + schemaNode.thenSchema && + !matchesSchemaNode(schemaNode.thenSchema, yamlNode, true)) { + const localizedMessage = localizeValidationMessage( + ValidationMessageKeys.thenViolation, + localizer, + { + displayPath: displayPath || "" + }); + + if (!reportedMessages.has(localizedMessage)) { + diagnostics.push({ + severity: "error", + message: localizedMessage + }); + reportedMessages.add(localizedMessage); + } + } + + if (!ifMatched && + schemaNode.ifSchema && + schemaNode.elseSchema && + !matchesSchemaNode(schemaNode.elseSchema, yamlNode, true)) { + const localizedMessage = localizeValidationMessage( + ValidationMessageKeys.elseViolation, + localizer, + { + displayPath: displayPath || "" + }); + + if (!reportedMessages.has(localizedMessage)) { + diagnostics.push({ + severity: "error", + message: localizedMessage + }); + reportedMessages.add(localizedMessage); + } + } + if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { diagnostics.push({ @@ -2028,6 +2189,22 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope } } + const ifMatched = schemaNode.ifSchema + ? matchesSchemaNodeInternal(schemaNode.ifSchema, yamlNode, true) + : false; + if (ifMatched && + schemaNode.thenSchema && + !matchesSchemaNodeInternal(schemaNode.thenSchema, yamlNode, true)) { + return false; + } + + if (!ifMatched && + schemaNode.ifSchema && + schemaNode.elseSchema && + !matchesSchemaNodeInternal(schemaNode.elseSchema, yamlNode, true)) { + return false; + } + if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { return false; @@ -2445,6 +2622,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.triggerProperty}”存在时,必须同时声明属性“${params.displayPath}”。`; case ValidationMessageKeys.dependentSchemasViolation: return `对象“${params.displayPath}”在属性“${params.triggerProperty}”存在时,必须满足对应的 dependent schema。`; + case ValidationMessageKeys.elseViolation: + return `对象“${params.displayPath}”在内联 \`if\` 条件未命中时,必须满足对应的 \`else\` schema。`; case ValidationMessageKeys.expectedArray: return `属性“${params.displayPath}”应为数组。`; case ValidationMessageKeys.expectedScalarShape: @@ -2473,6 +2652,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`; case ValidationMessageKeys.notViolation: return `属性“${params.displayPath}”不能匹配被 \`not\` 禁止的 schema。`; + case ValidationMessageKeys.thenViolation: + return `对象“${params.displayPath}”在内联 \`if\` 条件命中时,必须满足对应的 \`then\` schema。`; case ValidationMessageKeys.minContainsViolation: return `属性“${params.displayPath}”至少需要包含 ${params.value} 个匹配 contains 条件的元素。`; case ValidationMessageKeys.minItemsViolation: @@ -2501,6 +2682,8 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' is required when sibling property '${params.triggerProperty}' is present.`; case ValidationMessageKeys.dependentSchemasViolation: return `Object '${params.displayPath}' must satisfy the dependent schema triggered by sibling property '${params.triggerProperty}'.`; + case ValidationMessageKeys.elseViolation: + return `Object '${params.displayPath}' must satisfy the 'else' schema because the inline 'if' condition did not match.`; case ValidationMessageKeys.expectedArray: return `Property '${params.displayPath}' is expected to be an array.`; case ValidationMessageKeys.expectedScalarShape: @@ -2529,6 +2712,8 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' must be a multiple of ${params.value}.`; case ValidationMessageKeys.notViolation: return `Property '${params.displayPath}' must not match the forbidden 'not' schema.`; + case ValidationMessageKeys.thenViolation: + return `Object '${params.displayPath}' must satisfy the 'then' schema because the inline 'if' condition matched.`; case ValidationMessageKeys.minContainsViolation: return `Property '${params.displayPath}' must contain at least ${params.value} items matching the 'contains' schema.`; case ValidationMessageKeys.minItemsViolation: @@ -3252,6 +3437,9 @@ module.exports = { * dependentRequired?: Record, * dependentSchemas?: Record, * allOf?: SchemaNode[], + * ifSchema?: SchemaNode, + * thenSchema?: SchemaNode, + * elseSchema?: SchemaNode, * title?: string, * description?: string, * defaultValue?: string, diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 51453727..941d40bf 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1576,9 +1576,75 @@ function getScalarArrayValue(yamlNode) { } /** + * @typedef {object} InlineObjectSchemaHint + * @property {string=} type Inline schema type. + * @property {string[]=} required Required properties. + * @property {string[]=} enumValues Allowed enum values. + * @property {string=} constValue Raw const value. + * @property {string=} constDisplayValue Human-readable const value. + * @property {string=} pattern String pattern metadata. + * @property {string=} refTable Referenced table name. + * + * @typedef {object} ContainsSchemaHint + * @property {string=} type Inline schema type. + * @property {string[]=} enumValues Allowed enum values. + * @property {string=} constValue Raw const value. + * @property {string=} constDisplayValue Human-readable const value. + * @property {string=} pattern String pattern metadata. + * @property {string=} format String format metadata. + * @property {string=} refTable Referenced table name. + * + * @typedef {object} ScalarArrayItemHint + * @property {string[]=} enumValues Allowed enum values. + * @property {string=} constValue Raw const value. + * @property {string=} constDisplayValue Human-readable const value. + * @property {number=} minimum Inclusive minimum. + * @property {number=} exclusiveMinimum Exclusive minimum. + * @property {number=} maximum Inclusive maximum. + * @property {number=} exclusiveMaximum Exclusive maximum. + * @property {number=} multipleOf Numeric multiple constraint. + * @property {number=} minLength Minimum length. + * @property {number=} maxLength Maximum length. + * @property {string=} pattern String pattern metadata. + * @property {string=} format String format metadata. + * + * @typedef {object} PropertySchemaHint + * @property {string=} type Schema type. + * @property {string=} description Human-facing description. + * @property {string=} defaultValue Default value text. + * @property {string=} constValue Raw const value. + * @property {string=} constDisplayValue Human-readable const value. + * @property {number=} minimum Inclusive minimum. + * @property {number=} exclusiveMinimum Exclusive minimum. + * @property {number=} maximum Inclusive maximum. + * @property {number=} exclusiveMaximum Exclusive maximum. + * @property {number=} multipleOf Numeric multiple constraint. + * @property {number=} minLength Minimum length. + * @property {number=} maxLength Maximum length. + * @property {string=} pattern String pattern metadata. + * @property {string=} format String format metadata. + * @property {number=} minItems Minimum array item count. + * @property {number=} maxItems Maximum array item count. + * @property {number=} minContains Minimum contains matches. + * @property {number=} maxContains Maximum contains matches. + * @property {number=} minProperties Minimum property count. + * @property {number=} maxProperties Maximum property count. + * @property {string[]=} required Required properties. + * @property {Record=} dependentRequired dependentRequired metadata. + * @property {Record=} dependentSchemas dependentSchemas metadata. + * @property {Array=} allOf allOf metadata. + * @property {InlineObjectSchemaHint=} ifSchema if metadata. + * @property {InlineObjectSchemaHint=} thenSchema then metadata. + * @property {InlineObjectSchemaHint=} elseSchema else metadata. + * @property {boolean=} uniqueItems uniqueItems metadata. + * @property {string[]=} enumValues Allowed enum values. + * @property {ContainsSchemaHint=} contains contains metadata. + * @property {ScalarArrayItemHint=} items Array item metadata. + * @property {string=} refTable Referenced table name. + * * Render one compact inline-schema summary for form hints. * - * @param {{type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} schema Parsed inline schema metadata. + * @param {InlineObjectSchemaHint} schema Parsed inline schema metadata. * @param {boolean} includeRequiredProperties Whether object `required` members should be surfaced. * @returns {string} Localized summary. */ @@ -1622,7 +1688,7 @@ function describeInlineSchemaForHint(schema, includeRequiredProperties = false) /** * Render human-facing metadata hints for one schema field. * - * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, required?: string[], dependentRequired?: Record, dependentSchemas?: Record, allOf?: Array<{type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}>, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {PropertySchemaHint} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @param {boolean} includeDescription Whether description text should be included in the hint output. * @returns {string} HTML fragment. @@ -1732,6 +1798,24 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true } } + if (propertySchema.type === "object" && + propertySchema.ifSchema && + propertySchema.thenSchema) { + hints.push(escapeHtml(localizer.t("webview.hint.ifThen", { + condition: describeInlineSchemaForHint(propertySchema.ifSchema, true), + schema: describeInlineSchemaForHint(propertySchema.thenSchema, true) + }))); + } + + if (propertySchema.type === "object" && + propertySchema.ifSchema && + propertySchema.elseSchema) { + hints.push(escapeHtml(localizer.t("webview.hint.ifElse", { + condition: describeInlineSchemaForHint(propertySchema.ifSchema, true), + schema: describeInlineSchemaForHint(propertySchema.elseSchema, true) + }))); + } + if (isArrayField && typeof propertySchema.minItems === "number") { hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems}))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 099e41f7..b2684e9f 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -137,6 +137,8 @@ const enMessages = { "webview.hint.dependentRequired": "When {trigger} is set: require {dependencies}", "webview.hint.dependentSchemas": "When {trigger} is set: satisfy {schema}", "webview.hint.allOf": "Also satisfy: {schema}", + "webview.hint.ifThen": "When {condition}: satisfy {schema}", + "webview.hint.ifElse": "Otherwise (when {condition} does not match): satisfy {schema}", "webview.hint.refTable": "Ref table: {refTable}", "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.", @@ -146,6 +148,7 @@ const enMessages = { [ValidationMessageKeys.constMismatch]: "Property '{displayPath}' must match constant value {value}.", [ValidationMessageKeys.dependentRequiredViolation]: "Property '{displayPath}' is required when sibling property '{triggerProperty}' is present.", [ValidationMessageKeys.dependentSchemasViolation]: "Object '{displayPath}' must satisfy the dependent schema triggered by sibling property '{triggerProperty}'.", + [ValidationMessageKeys.elseViolation]: "Object '{displayPath}' must satisfy the 'else' schema because the inline 'if' condition did not match.", [ValidationMessageKeys.exclusiveMaximumViolation]: "Property '{displayPath}' must be less than {value}.", [ValidationMessageKeys.exclusiveMinimumViolation]: "Property '{displayPath}' must be greater than {value}.", [ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.", @@ -156,6 +159,7 @@ const enMessages = { [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.", [ValidationMessageKeys.notViolation]: "Property '{displayPath}' must not match the forbidden 'not' schema.", + [ValidationMessageKeys.thenViolation]: "Object '{displayPath}' must satisfy the 'then' schema because the inline 'if' condition matched.", [ValidationMessageKeys.minContainsViolation]: "Property '{displayPath}' must contain at least {value} items matching the 'contains' schema.", [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", @@ -263,6 +267,8 @@ const zhCnMessages = { "webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}", "webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足 {schema}", "webview.hint.allOf": "还必须满足:{schema}", + "webview.hint.ifThen": "当满足 {condition} 时:还必须满足 {schema}", + "webview.hint.ifElse": "否则(当 {condition} 不匹配时):还必须满足 {schema}", "webview.hint.refTable": "引用表:{refTable}", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", @@ -272,6 +278,7 @@ const zhCnMessages = { [ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。", [ValidationMessageKeys.dependentRequiredViolation]: "属性“{triggerProperty}”存在时,必须同时声明属性“{displayPath}”。", [ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的 dependent schema。", + [ValidationMessageKeys.elseViolation]: "对象“{displayPath}”在内联 `if` 条件未命中时,必须满足对应的 `else` schema。", [ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。", [ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。", [ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。", @@ -282,6 +289,7 @@ const zhCnMessages = { [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。", [ValidationMessageKeys.notViolation]: "属性“{displayPath}”不能匹配被 `not` 禁止的 schema。", + [ValidationMessageKeys.thenViolation]: "对象“{displayPath}”在内联 `if` 条件命中时,必须满足对应的 `then` schema。", [ValidationMessageKeys.minContainsViolation]: "属性“{displayPath}”至少需要包含 {value} 个匹配 contains 条件的元素。", [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 407baf3a..51699094 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -2,6 +2,7 @@ const ValidationMessageKeys = Object.freeze({ allOfViolation: "validation.allOfViolation", constMismatch: "validation.constMismatch", dependentSchemasViolation: "validation.dependentSchemasViolation", + elseViolation: "validation.elseViolation", enumMismatch: "validation.enumMismatch", exclusiveMaximumViolation: "validation.exclusiveMaximumViolation", exclusiveMinimumViolation: "validation.exclusiveMinimumViolation", @@ -18,6 +19,7 @@ const ValidationMessageKeys = Object.freeze({ minimumViolation: "validation.minimumViolation", multipleOfViolation: "validation.multipleOfViolation", notViolation: "validation.notViolation", + thenViolation: "validation.thenViolation", minContainsViolation: "validation.minContainsViolation", minItemsViolation: "validation.minItemsViolation", minLengthViolation: "validation.minLengthViolation", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 95623d68..dcfe00ab 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -2775,6 +2775,335 @@ test("createSampleConfigYaml should prefer scalar const values over defaults", ( assert.ok(!/^rarity: rare$/mu.test(sample)); }); +test("parseSchemaContent should capture object-focused if/then/else metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "then": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + }, + "else": { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "type": "integer" } + } + } + } + } + } + `); + + assert.equal(schema.properties.reward.ifSchema.type, "object"); + assert.equal(schema.properties.reward.thenSchema.type, "object"); + assert.deepEqual(schema.properties.reward.thenSchema.required, ["itemCount"]); + assert.deepEqual(schema.properties.reward.elseSchema.required, ["bonus"]); +}); + +test("parseSchemaContent should reject then declarations without if", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "then": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + } + `), + /must declare 'if' when using 'then' or 'else'/u); +}); + +test("parseSchemaContent should require explicit object type for conditional branches", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" } + }, + "if": { + "properties": { + "itemId": { "type": "string", "const": "potion" } + } + }, + "then": { + "type": "object", + "properties": { + "itemId": { "type": "string" } + } + } + } + } + } + `), + /must declare an object-typed 'if' schema/u); +}); + +test("parseSchemaContent should reject conditional branches with non-object properties metadata", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" } + }, + "if": { + "type": "object", + "properties": [] + }, + "then": { + "type": "object", + "properties": { + "itemId": { "type": "string" } + } + } + } + } + } + `), + /must declare 'properties' in 'if' as an object-valued map/u); +}); + +test("parseSchemaContent should reject conditional branches with non-array required metadata", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + } + }, + "then": { + "type": "object", + "required": "itemCount", + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + } + `), + /must declare 'required' in 'then' as an array of property names/u); +}); + +test("parseSchemaContent should reject conditional branches with invalid required entries", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "bonus": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "bonus": { "type": "integer" } + } + }, + "else": { + "type": "object", + "required": [" "], + "properties": { + "bonus": { "type": "integer" } + } + } + } + } + } + `), + /cannot declare blank property names in 'required' for 'else'/u); +}); + +test("validateParsedConfig should report then violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "then": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + } + `); + + const yaml = parseTopLevelYaml(` +reward: + itemId: potion +`); + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /'then' schema|`then` schema/u); +}); + +test("validateParsedConfig should report else violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "bonus": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "else": { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "type": "integer" } + } + } + } + } + } + `); + + const yaml = parseTopLevelYaml(` +reward: + itemId: sword +`); + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /'else' schema|`else` schema/u); +}); + +test("validateParsedConfig should accept satisfied if/then/else constraints", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + }, + "if": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "const": "potion" + } + } + }, + "then": { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + }, + "else": { + "type": "object", + "required": ["bonus"], + "properties": { + "bonus": { "type": "integer" } + } + } + } + } + } + `); + + const thenYaml = parseTopLevelYaml(` +reward: + itemId: potion + itemCount: 2 +`); + const elseYaml = parseTopLevelYaml(` +reward: + itemId: sword + bonus: 1 +`); + + assert.deepEqual(validateParsedConfig(schema, thenYaml), []); + assert.deepEqual(validateParsedConfig(schema, elseYaml), []); +}); + test("parseBatchArrayValue should keep comma-separated batch editing behavior", () => { assert.deepEqual(parseBatchArrayValue(" potion, bomb , ,elixir "), ["potion", "bomb", "elixir"]); }); diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index e3969560..52358254 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -174,3 +174,21 @@ test("createLocalizer should expose allOf validation keys", () => { }), "对象“reward”必须满足全部 `allOf` schema,第 1 项未匹配。"); }); + +test("createLocalizer should expose ifElse hints with the condition context", () => { + const englishLocalizer = createLocalizer("en"); + const chineseLocalizer = createLocalizer("zh-cn"); + + assert.equal( + englishLocalizer.t("webview.hint.ifElse", { + condition: "object, Required: itemId", + schema: "object, Required: bonus" + }), + "Otherwise (when object, Required: itemId does not match): satisfy object, Required: bonus"); + assert.equal( + chineseLocalizer.t("webview.hint.ifElse", { + condition: "object,必填字段:itemId", + schema: "object,必填字段:bonus" + }), + "否则(当 object,必填字段:itemId 不匹配时):还必须满足 object,必填字段:bonus"); +});