Compare commits

..

No commits in common. "5175f00178884df94cc1beabf0b8f09b27e92c5e" and "f99736f95ff12fde8b7cbd37f4e4cda304999350" have entirely different histories.

25 changed files with 170 additions and 3460 deletions

View File

@ -17,7 +17,6 @@ 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`
@ -40,7 +39,6 @@ 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
@ -56,7 +54,6 @@ 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

View File

@ -210,39 +210,19 @@ 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(
"<details>\n<summary>🤖 Prompt for all review comments with AI agents</summary>",
1,
)[0]
comments = parse_comment_cards(primary_block)
prompt_match = re.search(
r"<summary>🤖 Prompt for all review comments with AI agents</summary>\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"<summary>"
# 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"((?:[^<\n]+/)*[^<\n]+\.(?:cs|md|csproj|yaml|yml|json|txt|props|targets)|AGENTS\.md|CLAUDE\.md|README\.md|\.gitignore)"
r" \((\d+)\)</summary><blockquote>\s*(.*?)\s*(?:(?:</blockquote></details>)|(?:</blockquote>))",
re.S,
)
for path, _, body in pattern.findall(comment_block):
for path, _, body in pattern.findall(primary_block):
finding_match = re.search(r"`([^`]+)`: \*\*(.*?)\*\*", body, re.S)
prompt_match = re.search(r"<summary>🤖 Prompt for AI Agents</summary>\s*```(.*?)```", body, re.S)
suggestion_match = re.search(r"<summary>✏️ 建议文案调整</summary>\s*```diff(.*?)```", body, re.S)
@ -263,67 +243,17 @@ def parse_comment_cards(comment_block: str) -> list[dict[str, str]]:
}
)
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"<details>|</details>", review_body[block_start:]):
tag = tag_match.group(0)
if tag == "<details>":
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"<summary>[^<]*{re.escape(section_name)} \((?P<count>\d+)\)</summary><blockquote>\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*</blockquote>\s*$", "", comment_block, flags=re.S)
return {
"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"<summary>🤖 Prompt for all review comments with AI agents</summary>\s*```(.*?)```",
normalized_review_body,
actionable_block,
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"],
"count": count,
"comments": comments,
"all_comments_prompt": prompt_match.group(1).strip() if prompt_match else "",
"raw": review_body.strip(),
"raw": actionable_block.strip(),
}
@ -618,39 +548,12 @@ 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")
and not coderabbit_review.get("nitpick_comments")
):
if not actionable_block and not latest_commit_review.get("threads"):
warnings.append("CodeRabbit actionable comments block was not found in issue comments.")
return {
@ -668,7 +571,6 @@ 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],
@ -692,42 +594,15 @@ def format_text(result: dict[str, Any]) -> str:
lines.append(f" Explanation: {check['explanation']}")
lines.append(f" Resolution: {check['resolution']}")
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)
comments = result.get("coderabbit_comments", {}).get("comments", [])
lines.append("")
lines.append(f"CodeRabbit actionable comments: {actionable_count}")
lines.append(f"CodeRabbit actionable comments: {len(comments)}")
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", {})

View File

@ -398,15 +398,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
ITypeSymbol type,
out RuntimeTypeReferenceSpec? runtimeTypeReference)
{
// CLR forbids pointer and function-pointer types from being used as generic arguments.
// CQRS handler contracts are generic interfaces, so emitting runtime reconstruction code for these
// shapes would only defer the failure to MakeGenericType(...) at runtime.
if (type is IPointerTypeSymbol or IFunctionPointerTypeSymbol)
{
runtimeTypeReference = null;
return false;
}
if (CanReferenceFromGeneratedRegistry(compilation, type))
{
runtimeTypeReference = RuntimeTypeReferenceSpec.FromDirectReference(
@ -527,9 +518,8 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
}
return true;
case IPointerTypeSymbol:
case IFunctionPointerTypeSymbol:
return false;
case IPointerTypeSymbol pointerType:
return CanReferenceFromGeneratedRegistry(compilation, pointerType.PointedAtType);
case ITypeParameterSymbol:
return false;
default:
@ -985,18 +975,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
: $"{elementExpression}.MakeArrayType({runtimeTypeReference.ArrayRank})";
}
if (runtimeTypeReference.PointerElementTypeReference is not null)
{
var pointedAtExpression = AppendRuntimeTypeReferenceResolution(
builder,
runtimeTypeReference.PointerElementTypeReference,
$"{variableBaseName}PointedAt",
reflectedArgumentNames,
indent);
return $"{pointedAtExpression}.MakePointerType()";
}
if (runtimeTypeReference.GenericTypeDefinitionReference is not null)
{
var genericTypeDefinitionExpression = AppendRuntimeTypeReferenceResolution(
@ -1113,12 +1091,6 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
return true;
}
if (runtimeTypeReference.PointerElementTypeReference is not null &&
ContainsExternalAssemblyTypeLookup(runtimeTypeReference.PointerElementTypeReference))
{
return true;
}
if (runtimeTypeReference.GenericTypeDefinitionReference is not null &&
ContainsExternalAssemblyTypeLookup(runtimeTypeReference.GenericTypeDefinitionReference))
{
@ -1157,19 +1129,18 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
string? ReflectionAssemblyName,
RuntimeTypeReferenceSpec? ArrayElementTypeReference,
int ArrayRank,
RuntimeTypeReferenceSpec? PointerElementTypeReference,
RuntimeTypeReferenceSpec? GenericTypeDefinitionReference,
ImmutableArray<RuntimeTypeReferenceSpec> GenericTypeArguments)
{
public static RuntimeTypeReferenceSpec FromDirectReference(string typeDisplayName)
{
return new RuntimeTypeReferenceSpec(typeDisplayName, null, null, null, 0, null, null,
return new RuntimeTypeReferenceSpec(typeDisplayName, null, null, null, 0, null,
ImmutableArray<RuntimeTypeReferenceSpec>.Empty);
}
public static RuntimeTypeReferenceSpec FromReflectionLookup(string reflectionTypeMetadataName)
{
return new RuntimeTypeReferenceSpec(null, reflectionTypeMetadataName, null, null, 0, null, null,
return new RuntimeTypeReferenceSpec(null, reflectionTypeMetadataName, null, null, 0, null,
ImmutableArray<RuntimeTypeReferenceSpec>.Empty);
}
@ -1178,19 +1149,13 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
string reflectionTypeMetadataName)
{
return new RuntimeTypeReferenceSpec(null, reflectionTypeMetadataName, reflectionAssemblyName, null, 0,
null, null,
null,
ImmutableArray<RuntimeTypeReferenceSpec>.Empty);
}
public static RuntimeTypeReferenceSpec FromArray(RuntimeTypeReferenceSpec elementTypeReference, int arrayRank)
{
return new RuntimeTypeReferenceSpec(null, null, null, elementTypeReference, arrayRank, null, null,
ImmutableArray<RuntimeTypeReferenceSpec>.Empty);
}
public static RuntimeTypeReferenceSpec FromPointer(RuntimeTypeReferenceSpec pointedAtTypeReference)
{
return new RuntimeTypeReferenceSpec(null, null, null, null, 0, pointedAtTypeReference, null,
return new RuntimeTypeReferenceSpec(null, null, null, elementTypeReference, arrayRank, null,
ImmutableArray<RuntimeTypeReferenceSpec>.Empty);
}
@ -1198,7 +1163,7 @@ public sealed class CqrsHandlerRegistryGenerator : IIncrementalGenerator
RuntimeTypeReferenceSpec genericTypeDefinitionReference,
ImmutableArray<RuntimeTypeReferenceSpec> genericTypeArguments)
{
return new RuntimeTypeReferenceSpec(null, null, null, null, 0, null, genericTypeDefinitionReference,
return new RuntimeTypeReferenceSpec(null, null, null, null, 0, genericTypeDefinitionReference,
genericTypeArguments);
}
}

View File

@ -32,7 +32,6 @@ internal sealed class CqrsHandlerRegistrarTests
_container.Freeze();
_context = new ArchitectureContext(_container);
ClearRegistrarCaches();
}
/// <summary>
@ -44,7 +43,6 @@ internal sealed class CqrsHandlerRegistrarTests
_context = null;
_container = null;
DeterministicNotificationHandlerState.Reset();
ClearRegistrarCaches();
}
/// <summary>
@ -142,31 +140,6 @@ internal sealed class CqrsHandlerRegistrarTests
Is.EqualTo([typeof(GeneratedRegistryNotificationHandler)]));
}
/// <summary>
/// 验证 generated registry 使用私有无参构造器时,运行时仍可激活它并完成处理器注册。
/// </summary>
[Test]
public void RegisterHandlers_Should_Activate_Generated_Registry_With_Private_Parameterless_Constructor()
{
var generatedAssembly = new Mock<Assembly>();
generatedAssembly
.SetupGet(static assembly => assembly.FullName)
.Returns("GFramework.Core.Tests.Cqrs.PrivateGeneratedRegistryAssembly, Version=1.0.0.0");
generatedAssembly
.Setup(static assembly => assembly.GetCustomAttributes(typeof(CqrsHandlerRegistryAttribute), false))
.Returns([new CqrsHandlerRegistryAttribute(typeof(PrivateConstructorNotificationHandlerRegistry))]);
var container = new MicrosoftDiContainer();
CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object);
container.Freeze();
var handlers = container.GetAll<INotificationHandler<GeneratedRegistryNotification>>();
Assert.That(
handlers.Select(static handler => handler.GetType()),
Is.EqualTo([typeof(GeneratedRegistryNotificationHandler)]));
}
/// <summary>
/// 验证当生成注册器元数据损坏时,运行时会记录告警并回退到反射扫描路径。
/// </summary>
@ -437,150 +410,6 @@ internal sealed class CqrsHandlerRegistrarTests
partiallyLoadableAssembly.Verify(static assembly => assembly.GetTypes(), Times.Once);
}
/// <summary>
/// 验证同一 handler 类型跨容器重复注册时,会复用已筛选的 supported handler interface 列表,
/// 而不是为每个容器重新执行接口反射分析。
/// </summary>
[Test]
public void RegisterHandlers_Should_Cache_Supported_Handler_Interfaces_Across_Containers()
{
var supportedHandlerInterfacesCache = GetRegistrarCacheField("SupportedHandlerInterfacesCache");
var firstHandlerType = typeof(AlphaDeterministicNotificationHandler);
var secondHandlerType = typeof(ZetaDeterministicNotificationHandler);
var handlerAssembly = new Mock<Assembly>();
handlerAssembly
.SetupGet(static assembly => assembly.FullName)
.Returns("GFramework.Core.Tests.Cqrs.CachedHandlerInterfacesAssembly, Version=1.0.0.0");
handlerAssembly
.Setup(static assembly => assembly.GetTypes())
.Returns([firstHandlerType, secondHandlerType]);
Assert.Multiple(() =>
{
Assert.That(GetSingleKeyCacheValue(supportedHandlerInterfacesCache, firstHandlerType), Is.Null);
Assert.That(GetSingleKeyCacheValue(supportedHandlerInterfacesCache, secondHandlerType), Is.Null);
});
var firstContainer = new MicrosoftDiContainer();
var secondContainer = new MicrosoftDiContainer();
CqrsTestRuntime.RegisterHandlers(firstContainer, handlerAssembly.Object);
var firstHandlerInterfaces =
GetSingleKeyCacheValue(supportedHandlerInterfacesCache, firstHandlerType);
var secondHandlerInterfaces =
GetSingleKeyCacheValue(supportedHandlerInterfacesCache, secondHandlerType);
CqrsTestRuntime.RegisterHandlers(secondContainer, handlerAssembly.Object);
Assert.Multiple(() =>
{
Assert.That(firstHandlerInterfaces, Is.Not.Null);
Assert.That(secondHandlerInterfaces, Is.Not.Null);
Assert.That(
GetSingleKeyCacheValue(supportedHandlerInterfacesCache, firstHandlerType),
Is.SameAs(firstHandlerInterfaces));
Assert.That(
GetSingleKeyCacheValue(supportedHandlerInterfacesCache, secondHandlerType),
Is.SameAs(secondHandlerInterfaces));
});
handlerAssembly.Verify(static assembly => assembly.GetTypes(), Times.Once);
}
/// <summary>
/// 验证当程序集枚举结果包含重复 handler 类型时registrar 仍只会写入一份 handler 映射。
/// </summary>
[Test]
public void RegisterHandlers_Should_Skip_Duplicate_Handler_Mappings_When_Assembly_Returns_Duplicate_Types()
{
var handlerType = typeof(AlphaDeterministicNotificationHandler);
var handlerAssembly = new Mock<Assembly>();
handlerAssembly
.SetupGet(static assembly => assembly.FullName)
.Returns("GFramework.Core.Tests.Cqrs.DuplicateHandlerMappingsAssembly, Version=1.0.0.0");
handlerAssembly
.Setup(static assembly => assembly.GetTypes())
.Returns([handlerType, handlerType]);
var container = new MicrosoftDiContainer();
CqrsTestRuntime.RegisterHandlers(container, handlerAssembly.Object);
var registrations = container.GetServicesUnsafe
.Where(static descriptor =>
descriptor.ServiceType == typeof(INotificationHandler<DeterministicOrderNotification>) &&
descriptor.ImplementationType == typeof(AlphaDeterministicNotificationHandler))
.ToArray();
Assert.That(registrations, Has.Length.EqualTo(1));
}
/// <summary>
/// 清空本测试依赖的 registrar 静态缓存,避免跨用例共享进程级状态导致断言漂移。
/// </summary>
private static void ClearRegistrarCaches()
{
ClearCache(GetRegistrarCacheField("AssemblyMetadataCache"));
ClearCache(GetRegistrarCacheField("RegistryActivationMetadataCache"));
ClearCache(GetRegistrarCacheField("LoadableTypesCache"));
ClearCache(GetRegistrarCacheField("SupportedHandlerInterfacesCache"));
}
/// <summary>
/// 通过反射读取 registrar 的静态缓存对象。
/// </summary>
private static object GetRegistrarCacheField(string fieldName)
{
var registrarType = GetRegistrarType();
var field = registrarType.GetField(
fieldName,
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(field, Is.Not.Null, $"Missing registrar cache field {fieldName}.");
return field!.GetValue(null)
?? throw new InvalidOperationException(
$"Registrar cache field {fieldName} returned null.");
}
/// <summary>
/// 清空指定缓存对象。
/// </summary>
private static void ClearCache(object cache)
{
_ = InvokeInstanceMethod(cache, "Clear");
}
/// <summary>
/// 读取单键缓存中当前保存的对象。
/// </summary>
private static object? GetSingleKeyCacheValue(object cache, Type key)
{
return InvokeInstanceMethod(cache, "GetValueOrDefaultForTesting", key);
}
/// <summary>
/// 调用缓存对象上的实例方法。
/// </summary>
private static object? InvokeInstanceMethod(object target, string methodName, params object[] arguments)
{
var method = target.GetType().GetMethod(
methodName,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null, $"Missing cache method {target.GetType().FullName}.{methodName}.");
return method!.Invoke(target, arguments);
}
/// <summary>
/// 获取 CQRS handler registrar 运行时类型。
/// </summary>
private static Type GetRegistrarType()
{
return typeof(CqrsReflectionFallbackAttribute).Assembly
.GetType("GFramework.Cqrs.Internal.CqrsHandlerRegistrar", throwOnError: true)!;
}
}
/// <summary>
@ -779,33 +608,3 @@ internal sealed class PartialGeneratedNotificationHandlerRegistry : ICqrsHandler
$"Registered CQRS handler {typeof(GeneratedRegistryNotificationHandler).FullName} as {typeof(INotificationHandler<GeneratedRegistryNotification>).FullName}.");
}
}
/// <summary>
/// 模拟生成注册器使用私有无参构造器的场景,验证运行时仍可通过缓存工厂激活它。
/// </summary>
internal sealed class PrivateConstructorNotificationHandlerRegistry : ICqrsHandlerRegistry
{
/// <summary>
/// 初始化一个新的私有生成注册器实例。
/// </summary>
private PrivateConstructorNotificationHandlerRegistry()
{
}
/// <summary>
/// 将测试通知处理器注册到目标服务集合。
/// </summary>
/// <param name="services">承载处理器映射的服务集合。</param>
/// <param name="logger">用于记录注册诊断的日志器。</param>
public void Register(IServiceCollection services, ILogger logger)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(logger);
services.AddTransient(
typeof(INotificationHandler<GeneratedRegistryNotification>),
typeof(GeneratedRegistryNotificationHandler));
logger.Debug(
$"Registered CQRS handler {typeof(GeneratedRegistryNotificationHandler).FullName} as {typeof(INotificationHandler<GeneratedRegistryNotification>).FullName}.");
}
}

View File

@ -1,7 +1,6 @@
using GFramework.Core.Abstractions.Ioc;
using GFramework.Core.Abstractions.Logging;
using GFramework.Cqrs.Abstractions.Cqrs;
using System.Reflection.Emit;
namespace GFramework.Cqrs.Internal;
@ -26,11 +25,6 @@ internal static class CqrsHandlerRegistrar
private static readonly WeakKeyCache<Assembly, IReadOnlyList<Type>> LoadableTypesCache =
new();
// 卸载安全的进程级缓存:同一 handler 类型跨容器重复注册时,
// 复用已筛选且排序好的 supported handler interface 列表,避免重复执行 GetInterfaces()。
private static readonly WeakKeyCache<Type, IReadOnlyList<Type>> SupportedHandlerInterfacesCache =
new();
/// <summary>
/// 扫描指定程序集并注册所有 CQRS 请求/通知/流式处理器。
/// </summary>
@ -165,18 +159,21 @@ internal static class CqrsHandlerRegistrar
ILogger logger,
ReflectionFallbackMetadata? reflectionFallbackMetadata)
{
var registeredMappings = CreateRegisteredHandlerMappings(services);
foreach (var implementationType in GetCandidateHandlerTypes(assembly, logger, reflectionFallbackMetadata)
.Where(IsConcreteHandlerType))
{
var handlerInterfaces = GetSupportedHandlerInterfaces(implementationType);
var handlerInterfaces = implementationType
.GetInterfaces()
.Where(IsSupportedHandlerInterface)
.OrderBy(GetTypeSortKey, StringComparer.Ordinal)
.ToList();
if (handlerInterfaces.Count == 0)
continue;
foreach (var handlerInterface in handlerInterfaces)
{
if (!registeredMappings.Add(new HandlerMapping(handlerInterface, implementationType)))
if (IsHandlerMappingAlreadyRegistered(services, handlerInterface, implementationType))
{
logger.Debug(
$"Skipping duplicate CQRS handler {implementationType.FullName} as {handlerInterface.FullName}.");
@ -186,45 +183,12 @@ internal static class CqrsHandlerRegistrar
// Request/notification handlers receive context injection before every dispatch.
// Transient registration avoids sharing mutable Context across concurrent requests.
services.AddTransient(handlerInterface, implementationType);
logger.Debug(
logger.Debug(
$"Registered CQRS handler {implementationType.FullName} as {handlerInterface.FullName}.");
}
}
}
/// <summary>
/// 获取指定实现类型上所有受支持的 CQRS handler 接口,并缓存筛选与排序结果。
/// </summary>
/// <param name="implementationType">要分析的处理器实现类型。</param>
/// <returns>当前实现类型声明的受支持 handler 接口列表。</returns>
private static IReadOnlyList<Type> GetSupportedHandlerInterfaces(Type implementationType)
{
ArgumentNullException.ThrowIfNull(implementationType);
return SupportedHandlerInterfacesCache.GetOrAdd(
implementationType,
static key => key
.GetInterfaces()
.Where(IsSupportedHandlerInterface)
.OrderBy(GetTypeSortKey, StringComparer.Ordinal)
.ToArray());
}
/// <summary>
/// 根据当前服务集合创建已注册 handler 映射的快速索引,避免 reflection fallback 路径重复线性扫描服务描述符。
/// </summary>
/// <param name="services">当前容器的服务描述符集合。</param>
/// <returns>已存在的 handler 映射集合。</returns>
private static HashSet<HandlerMapping> CreateRegisteredHandlerMappings(IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
return services
.Where(static descriptor => descriptor.ImplementationType is not null)
.Select(static descriptor => new HandlerMapping(descriptor.ServiceType, descriptor.ImplementationType!))
.ToHashSet();
}
/// <summary>
/// 根据生成器提供的 fallback 清单或整程序集扫描结果,获取本轮要注册的候选处理器类型。
/// </summary>
@ -359,51 +323,7 @@ internal static class CqrsHandlerRegistrar
: new RegistryActivationMetadata(
true,
false,
CreateRegistryFactory(registryType, constructor));
}
/// <summary>
/// 为生成注册器创建可复用的激活工厂,优先使用一次性编译的动态方法,
/// 避免后续每次命中缓存时仍走 <see cref="ConstructorInfo" /> 的反射激活路径。
/// </summary>
/// <param name="registryType">生成注册器类型。</param>
/// <param name="constructor">已解析的无参构造函数。</param>
/// <returns>可直接实例化注册器的工厂委托。</returns>
private static Func<ICqrsHandlerRegistry> CreateRegistryFactory(
Type registryType,
ConstructorInfo constructor)
{
ArgumentNullException.ThrowIfNull(registryType);
ArgumentNullException.ThrowIfNull(constructor);
try
{
// 生成器产物通常是稳定的无参 registry这里把构造反射收敛为一次性 IL 工厂,
// 这样同一 registry 类型在多个容器间复用缓存时不会重复付出 ConstructorInfo.Invoke 成本。
var dynamicMethod = new DynamicMethod(
$"Create_{registryType.Name}_CqrsHandlerRegistry",
typeof(ICqrsHandlerRegistry),
Type.EmptyTypes,
registryType.Module,
skipVisibility: true);
var il = dynamicMethod.GetILGenerator();
il.Emit(OpCodes.Newobj, constructor);
if (registryType.IsValueType)
{
il.Emit(OpCodes.Box, registryType);
}
il.Emit(OpCodes.Castclass, typeof(ICqrsHandlerRegistry));
il.Emit(OpCodes.Ret);
return (Func<ICqrsHandlerRegistry>)dynamicMethod.CreateDelegate(typeof(Func<ICqrsHandlerRegistry>));
}
catch
{
// 某些受限运行环境若不允许动态方法,仍保留原有的反射激活语义,避免阻塞 generated registry 路径。
return () => (ICqrsHandlerRegistry)constructor.Invoke(null);
}
() => (ICqrsHandlerRegistry)constructor.Invoke(null));
}
/// <summary>
@ -471,6 +391,21 @@ internal static class CqrsHandlerRegistrar
definition == typeof(IStreamRequestHandler<,>);
}
/// <summary>
/// 判断同一 handler 映射是否已经由生成注册器或先前扫描步骤写入服务集合。
/// </summary>
private static bool IsHandlerMappingAlreadyRegistered(
IServiceCollection services,
Type handlerInterface,
Type implementationType)
{
// 这里保持线性扫描,避免为常见的小到中等规模程序集长期维护额外索引。
// 若未来大型服务集合出现热点,可在更高层批处理中引入 HashSet<(Type, Type)> 做 O(1) 去重。
return services.Any(descriptor =>
descriptor.ServiceType == handlerInterface &&
descriptor.ImplementationType == implementationType);
}
/// <summary>
/// 生成程序集排序键,保证跨运行环境的处理器注册顺序稳定。
/// </summary>
@ -487,8 +422,6 @@ internal static class CqrsHandlerRegistrar
return type.FullName ?? type.Name;
}
private readonly record struct HandlerMapping(Type ServiceType, Type ImplementationType);
private readonly record struct GeneratedRegistrationResult(
bool UsedGeneratedRegistry,
bool RequiresReflectionFallback,

View File

@ -17,4 +17,3 @@
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

View File

@ -9,8 +9,7 @@ namespace GFramework.Game.SourceGenerators.Config;
/// 当前共享子集也会把 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>、
/// <c>dependentSchemas</c>、<c>allOf</c>、object-focused <c>if</c> / <c>then</c> / <c>else</c>
/// 与稳定字符串 <c>format</c> 子集写入生成代码文档,
/// <c>dependentSchemas</c>、<c>allOf</c> 与稳定字符串 <c>format</c> 子集写入生成代码文档,
/// 让消费者能直接在强类型 API 上看到运行时生效且不改变生成类型形状的约束。
/// </summary>
[Generator]
@ -170,15 +169,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return SchemaParseResult.FromDiagnostic(allOfDiagnostic!);
}
if (!TryValidateConditionalSchemasMetadataRecursively(
file.Path,
"<root>",
root,
out var conditionalDiagnostic))
{
return SchemaParseResult.FromDiagnostic(conditionalDiagnostic!);
}
var entityName = ToPascalCase(GetSchemaBaseName(file.Path));
var rootObject = ParseObjectSpec(
file.Path,
@ -700,8 +690,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// <summary>
/// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>allOf</c> /
/// <c>if</c> / <c>then</c> / <c>else</c> / <c>not</c> 子 schema、
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>allOf</c> / <c>not</c> 子 schema、
/// 数组 <c>items</c> 与 <c>contains</c>
/// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。
/// </summary>
@ -806,45 +795,6 @@ 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(
@ -900,17 +850,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return $"{displayPath}[allOf[{allOfIndex}]]";
}
/// <summary>
/// 为 object-focused 条件分支生成与运行时一致的逻辑路径。
/// </summary>
/// <param name="displayPath">父对象路径。</param>
/// <param name="keywordName">条件关键字名称。</param>
/// <returns>格式化后的条件分支路径。</returns>
private static string BuildConditionalSchemaPath(string displayPath, string keywordName)
{
return $"{displayPath}[{keywordName}]";
}
/// <summary>
/// 递归验证 schema 树中的对象级 <c>dependentRequired</c> 元数据。
/// 该遍历会覆盖根节点、<c>dependentSchemas</c> / <c>not</c> 子 schema、
@ -1397,333 +1336,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return true;
}
/// <summary>
/// 验证当前 schema 节点是否以运行时支持的方式声明了 object-focused <c>if</c> / <c>then</c> / <c>else</c>。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="schemaType">当前节点声明的 schema 类型。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点上的条件元数据是否有效。</returns>
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);
}
/// <summary>
/// 递归验证 schema 树中的 object-focused <c>if</c> / <c>then</c> / <c>else</c> 元数据。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点树的条件元数据是否有效。</returns>
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);
}
/// <summary>
/// 验证单个对象 schema 节点上的 object-focused 条件元数据。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前对象 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前对象上的条件元数据是否有效。</returns>
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<string>(
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);
}
/// <summary>
/// 验证单个 object-focused 条件分支的类型与父对象字段引用范围。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">父对象逻辑路径。</param>
/// <param name="schemaElement">当前条件分支 schema。</param>
/// <param name="keywordName">条件关键字名称。</param>
/// <param name="declaredProperties">父对象已声明属性集合。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前条件分支是否有效。</returns>
private static bool TryValidateConditionalSchemaBranch(
string filePath,
string displayPath,
JsonElement schemaElement,
string keywordName,
ISet<string> 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);
}
/// <summary>
/// 验证 object-focused 内联 schema 只引用父对象已声明的同级字段。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">当前内联 schema 路径。</param>
/// <param name="entryLabel">用于诊断文本的条目标签。</param>
/// <param name="schemaElement">当前内联 schema。</param>
/// <param name="declaredProperties">父对象已声明属性集合。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前内联 schema 是否有效。</returns>
private static bool TryValidateObjectFocusedSchemaTargets(
string filePath,
string displayPath,
string entryLabel,
JsonElement schemaElement,
ISet<string> 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;
}
/// <summary>
/// 验证单个 <c>allOf</c> 条目只约束父对象已声明的同级字段。
/// </summary>
@ -3928,7 +3540,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// <summary>
/// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains、
/// 对象属性数量 / dependent* / allOf / if-then-else 约束整理成 XML 文档可读字符串。
/// 对象属性数量 / dependent* / allOf 约束整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaType">标量类型。</param>
@ -4081,12 +3693,6 @@ 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;
@ -4201,103 +3807,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
: null;
}
/// <summary>
/// 将对象 <c>if</c> / <c>then</c> / <c>else</c> 条件约束整理成 XML 文档可读字符串。
/// </summary>
/// <param name="element">对象 schema 节点。</param>
/// <returns>格式化后的条件约束说明。</returns>
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<string> { $"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;
}
/// <summary>
/// 汇总条件分支的对象级约束与子属性约束,避免生成文档只保留笼统的 object 描述。
/// </summary>
/// <param name="branchElement">条件分支 schema。</param>
/// <returns>格式化后的条件分支摘要。</returns>
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}";
}
/// <summary>
/// 汇总对象 <c>properties</c> 内每个字段的紧凑约束,补足条件分支文档里的触发条件细节。
/// </summary>
/// <param name="schemaElement">对象 schema 节点。</param>
/// <returns>格式化后的子属性约束摘要。</returns>
private static string? TryBuildInlineObjectPropertiesSummary(JsonElement schemaElement)
{
if (!schemaElement.TryGetProperty("properties", out var propertiesElement) ||
propertiesElement.ValueKind != JsonValueKind.Object)
{
return null;
}
var parts = new List<string>();
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)} }}";
}
/// <summary>
/// 将数组 <c>contains</c> 子 schema 整理成 XML 文档可读字符串。
/// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。

View File

@ -140,15 +140,4 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
/// <summary>
/// schema 对象节点的 if/then/else 条件元数据无效。
/// </summary>
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);
}

View File

@ -1,570 +0,0 @@
using System.IO;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证 YAML 配置加载器对 object-focused <c>if</c> / <c>then</c> / <c>else</c> 约束的运行时行为。
/// </summary>
[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;
/// <summary>
/// 为每个用例创建隔离的临时目录,避免不同条件分支场景互相污染。
/// </summary>
[SetUp]
public void SetUp()
{
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_rootPath);
}
/// <summary>
/// 清理当前测试创建的目录,避免本地临时文件堆积。
/// </summary>
[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
}
}
}
/// <summary>
/// 验证 <c>if</c> 命中而 <c>then</c> 约束未满足时,运行时会拒绝加载。
/// </summary>
[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<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证 <c>if</c> 命中且 <c>then</c> 约束满足时可以正常加载。
/// </summary>
[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<int, MonsterConditionalConfigStub>("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));
});
}
/// <summary>
/// 验证 <c>if</c> 未命中而 <c>else</c> 约束未满足时,运行时会拒绝加载。
/// </summary>
[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<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证 <c>if</c> 未命中且 <c>else</c> 约束满足时可以正常加载。
/// </summary>
[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<int, MonsterConditionalConfigStub>("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));
});
}
/// <summary>
/// 验证非对象字段声明 <c>if</c> 时,会在 schema 解析阶段被拒绝。
/// </summary>
[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<int, MonsterTagConfigStub>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
var registry = CreateRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证缺少 <c>if</c> 却声明 <c>then</c> 时,会在 schema 解析阶段被拒绝。
/// </summary>
[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<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证缺少 <c>if</c> 却声明 <c>else</c> 时,会在 schema 解析阶段被拒绝。
/// </summary>
[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<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证条件分支不能要求父对象未声明的字段。
/// </summary>
[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<ConfigLoadException>(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));
});
}
/// <summary>
/// 写入测试配置文件,复用统一的测试文件创建逻辑。
/// </summary>
/// <param name="relativePath">配置文件相对路径。</param>
/// <param name="content">配置文件内容。</param>
private void CreateConfigFile(string relativePath, string content)
{
ArgumentNullException.ThrowIfNull(_rootPath);
var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
var directoryPath = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
File.WriteAllText(filePath, content);
}
/// <summary>
/// 写入测试 schema 文件,复用统一的测试文件创建逻辑。
/// </summary>
/// <param name="relativePath">schema 相对路径。</param>
/// <param name="content">schema JSON 内容。</param>
private void CreateSchemaFile(string relativePath, string content)
{
CreateConfigFile(relativePath, content);
}
/// <summary>
/// 构建带有指定奖励内容的怪物配置 YAML 文本。
/// </summary>
/// <param name="rewardYaml">奖励对象的 YAML 片段。</param>
/// <returns>完整的怪物配置 YAML 文本。</returns>
private static string BuildMonsterConfigYaml(string rewardYaml)
{
return $$"""
id: 1
reward:
{{IndentLines(rewardYaml, 2)}}
""";
}
/// <summary>
/// 构建带有指定奖励属性和条件约束的怪物 schema JSON。
/// </summary>
/// <param name="rewardPropertiesJson">奖励对象的 properties JSON 片段。</param>
/// <param name="conditionalJson">条件约束的 JSON 条目片段。</param>
/// <returns>完整的 schema JSON 文本。</returns>
private static string BuildMonsterSchema(
string rewardPropertiesJson,
string conditionalJson)
{
return $$"""
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {{rewardPropertiesJson}},
{{conditionalJson}}
}
}
}
""";
}
/// <summary>
/// 为多行文本的每一行添加指定数量的空格缩进。
/// </summary>
/// <param name="text">原始文本。</param>
/// <param name="indentLevel">缩进空格数。</param>
/// <returns>添加缩进后的文本。</returns>
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}"));
}
/// <summary>
/// 创建用于 object-focused 条件分支场景的加载器。
/// </summary>
/// <returns>已注册测试表与 schema 路径的加载器。</returns>
private YamlConfigLoader CreateMonsterRewardLoader()
{
ArgumentNullException.ThrowIfNull(_rootPath);
return new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConditionalConfigStub>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
}
/// <summary>
/// 创建新的配置注册表,确保每个用例从干净状态开始。
/// </summary>
/// <returns>空的配置注册表。</returns>
private static ConfigRegistry CreateRegistry()
{
return new ConfigRegistry();
}
/// <summary>
/// 用于 object-focused 条件分支回归测试的最小配置类型。
/// </summary>
private sealed class MonsterConditionalConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置奖励对象。
/// </summary>
public ConditionalRewardConfigStub Reward { get; set; } = new();
}
/// <summary>
/// 表示条件分支回归测试中的奖励节点。
/// </summary>
private sealed class ConditionalRewardConfigStub
{
/// <summary>
/// 获取或设置掉落物 ID。
/// </summary>
public string ItemId { get; set; } = string.Empty;
/// <summary>
/// 获取或设置掉落物数量。
/// </summary>
public int ItemCount { get; set; }
/// <summary>
/// 获取或设置额外奖励值。
/// </summary>
public int Bonus { get; set; }
}
/// <summary>
/// 用于非对象条件关键字场景回归测试的最小配置类型。
/// </summary>
private sealed class MonsterTagConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置标签。
/// </summary>
public string Tag { get; set; } = string.Empty;
}
}

View File

@ -5,8 +5,7 @@ namespace GFramework.Game.Config;
/// <summary>
/// 承载对象级 schema 关键字的解析与元数据校验逻辑。
/// 该 partial 将 <c>minProperties</c>、<c>maxProperties</c>、
/// <c>dependentRequired</c>、<c>dependentSchemas</c>、<c>allOf</c>
/// 与 object-focused <c>if</c> / <c>then</c> / <c>else</c>
/// <c>dependentRequired</c>、<c>dependentSchemas</c> 与 <c>allOf</c>
/// 从主校验文件中拆出,降低超大文件继续堆叠对象关键字时的维护成本。
/// </summary>
internal static partial class YamlConfigSchemaValidator
@ -42,7 +41,6 @@ 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)
{
@ -56,15 +54,9 @@ internal static partial class YamlConfigSchemaValidator
}
return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null &&
allOfSchemas is null && conditionalSchemas is null
allOfSchemas is null
? null
: new YamlConfigObjectConstraints(
minProperties,
maxProperties,
dependentRequired,
dependentSchemas,
allOfSchemas,
conditionalSchemas);
: new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas, allOfSchemas);
}
/// <summary>
@ -304,12 +296,12 @@ internal static partial class YamlConfigSchemaValidator
}
var allOfSchemaPath = BuildNestedSchemaPath(propertyPath, $"allOf[{allOfIndex.ToString(CultureInfo.InvariantCulture)}]");
ValidateInlineObjectSchemaTargetsAgainstParentObject(
ValidateAllOfSchemaTargetsAgainstParentObject(
tableName,
schemaPath,
propertyPath,
allOfSchemaPath,
$"Entry #{(allOfIndex + 1).ToString(CultureInfo.InvariantCulture)} in 'allOf'",
allOfIndex + 1,
allOfSchemaElement,
properties);
var allOfSchemaNode = ParseNode(
@ -337,176 +329,39 @@ internal static partial class YamlConfigSchemaValidator
}
/// <summary>
/// 解析对象节点声明的 object-focused <c>if</c> / <c>then</c> / <c>else</c> 条件约束。
/// 当前共享子集要求三段内联 schema 都保持 object-typed focused block 语义,
/// 既允许根据 sibling 值切换约束分支,又避免把条件 schema 扩展成新的生成类型形状。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">对象字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="properties">父对象已声明的属性集合。</param>
/// <returns>归一化后的条件约束;未声明时返回空。</returns>
private static YamlConfigConditionalSchemas? ParseConditionalSchemasConstraints(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
IReadOnlyDictionary<string, YamlConfigSchemaNode> 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);
}
/// <summary>
/// 解析单个条件分支的 object-focused 内联 schema。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">父对象路径。</param>
/// <param name="conditionalSchemaPath">当前条件分支路径。</param>
/// <param name="keywordName">条件关键字名称。</param>
/// <param name="conditionalSchemaElement">当前条件分支 schema。</param>
/// <param name="properties">父对象已声明的属性集合。</param>
/// <returns>解析后的 object-typed schema。</returns>
private static YamlConfigSchemaNode ParseConditionalObjectSchema(
string tableName,
string schemaPath,
string propertyPath,
string conditionalSchemaPath,
string keywordName,
JsonElement conditionalSchemaElement,
IReadOnlyDictionary<string, YamlConfigSchemaNode> 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));
}
/// <summary>
/// 验证 object-focused 内联 schema 只约束父对象已经声明过的同级字段。
/// 当前 shared subset 不会把 focused block 内字段并回父对象形状,因此这里会提前拒绝
/// 验证 <c>allOf</c> 条目只约束父对象已经声明过的同级字段。
/// 当前 object-focused 语义不会把条目里的属性并回父对象形状,因此这里要提前拒绝
/// “在 focused block 里引入父对象未声明字段”的不可满足 schema。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">父对象路径。</param>
/// <param name="inlineSchemaPath">当前内联 schema 路径。</param>
/// <param name="entryLabel">用于诊断文本的条目标签。</param>
/// <param name="inlineSchemaElement">当前内联 schema。</param>
/// <param name="allOfSchemaPath">当前 allOf 条目路径。</param>
/// <param name="allOfEntryNumber">从 1 开始的 allOf 条目编号。</param>
/// <param name="allOfSchemaElement">当前 allOf 条目。</param>
/// <param name="properties">父对象已声明的属性集合。</param>
private static void ValidateInlineObjectSchemaTargetsAgainstParentObject(
private static void ValidateAllOfSchemaTargetsAgainstParentObject(
string tableName,
string schemaPath,
string propertyPath,
string inlineSchemaPath,
string entryLabel,
JsonElement inlineSchemaElement,
string allOfSchemaPath,
int allOfEntryNumber,
JsonElement allOfSchemaElement,
IReadOnlyDictionary<string, YamlConfigSchemaNode> properties)
{
if (inlineSchemaElement.TryGetProperty("properties", out var inlinePropertiesElement))
if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement))
{
if (inlinePropertiesElement.ValueKind != JsonValueKind.Object)
if (allOfPropertiesElement.ValueKind != JsonValueKind.Object)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'properties' as an object-valued map.",
$"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'properties' as an object-valued map.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(inlineSchemaPath));
displayPath: GetDiagnosticPath(allOfSchemaPath));
}
foreach (var property in inlinePropertiesElement.EnumerateObject())
foreach (var property in allOfPropertiesElement.EnumerateObject())
{
if (properties.ContainsKey(property.Name))
{
@ -516,37 +371,37 @@ internal static partial class YamlConfigSchemaValidator
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' declares property '{property.Name}', but that property is not declared in the parent object schema.",
$"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.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(inlineSchemaPath));
displayPath: GetDiagnosticPath(allOfSchemaPath));
}
}
if (!inlineSchemaElement.TryGetProperty("required", out var inlineRequiredElement))
if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement))
{
return;
}
if (inlineRequiredElement.ValueKind != JsonValueKind.Array)
if (allOfRequiredElement.ValueKind != JsonValueKind.Array)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' as an array of property names.",
$"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' as an array of property names.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(inlineSchemaPath));
displayPath: GetDiagnosticPath(allOfSchemaPath));
}
foreach (var requiredProperty in inlineRequiredElement.EnumerateArray())
foreach (var requiredProperty in allOfRequiredElement.EnumerateArray())
{
if (requiredProperty.ValueKind != JsonValueKind.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' entries as property-name strings.",
$"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' entries as property-name strings.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(inlineSchemaPath));
displayPath: GetDiagnosticPath(allOfSchemaPath));
}
var requiredPropertyName = requiredProperty.GetString();
@ -555,9 +410,9 @@ internal static partial class YamlConfigSchemaValidator
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank property names in 'required'.",
$"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank property names in 'required'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(inlineSchemaPath));
displayPath: GetDiagnosticPath(allOfSchemaPath));
}
if (properties.ContainsKey(requiredPropertyName))
@ -568,9 +423,9 @@ internal static partial class YamlConfigSchemaValidator
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"{entryLabel} for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' requires property '{requiredPropertyName}', but that property is not declared in the parent object schema.",
$"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.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(inlineSchemaPath));
displayPath: GetDiagnosticPath(allOfSchemaPath));
}
}

View File

@ -11,10 +11,9 @@ namespace GFramework.Game.Config;
/// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>、
/// <c>dependentSchemas</c>、<c>allOf</c>、object-focused <c>if</c> / <c>then</c> / <c>else</c>
/// <c>dependentSchemas</c>、<c>allOf</c>
/// 与稳定字符串 <c>format</c> 子集,让数值步进、数组去重、数组匹配计数、
/// 对象属性数量、对象内字段依赖、条件对象子 schema、对象组合约束与条件分支约束
/// 在运行时与生成器 / 工具侧保持一致。
/// 对象属性数量、对象内字段依赖、条件对象子 schema 与对象组合约束在运行时与生成器 / 工具侧保持一致。
/// </summary>
internal static partial class YamlConfigSchemaValidator
{
@ -327,12 +326,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) &&
TryGetObjectOnlyKeywordName(element) is { } objectOnlyKeywordName)
element.TryGetProperty("allOf", out _))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' can only declare '{objectOnlyKeywordName}' on object schemas.",
$"Property '{propertyPath}' in schema file '{schemaPath}' can only declare 'allOf' on object schemas.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
@ -827,8 +826,7 @@ internal static partial class YamlConfigSchemaValidator
/// 校验对象节点声明的数量约束与条件对象约束。
/// 该阶段除了检查 <c>minProperties</c> / <c>maxProperties</c>,还会复用同一份 sibling 集合处理
/// <c>dependentRequired</c>,并在 <c>dependentSchemas</c> 命中时以 focused constraint block 语义
/// 对整个 <paramref name="mappingNode" /> 做额外试匹配;若声明了 object-focused
/// <c>if</c> / <c>then</c> / <c>else</c>,则先按同样的 focused matcher 判断条件分支,再只对命中的分支追加约束。
/// 对整个 <paramref name="mappingNode" /> 做额外试匹配。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
@ -837,7 +835,7 @@ internal static partial class YamlConfigSchemaValidator
/// <param name="seenProperties">当前对象已出现的属性集合。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <param name="references">
/// 可选的跨表引用收集器;当 <c>dependentSchemas</c>、<c>allOf</c> 或条件分支命中且匹配成功时,
/// 可选的跨表引用收集器;当 <c>dependentSchemas</c> 或 <c>allOf</c> 命中且匹配成功时,
/// 只会回写对应内联分支新增的引用。
/// </param>
private static void ValidateObjectConstraints(
@ -966,99 +964,40 @@ internal static partial class YamlConfigSchemaValidator
}
}
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)
if (constraints.AllOfSchemas is null ||
constraints.AllOfSchemas.Count == 0)
{
return;
}
// 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))
for (var index = 0; index < constraints.AllOfSchemas.Count; index++)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{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:
"Conditional schema: the current object matched the inline 'if' schema, so it must also satisfy the corresponding 'then' schema.");
}
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;
}
if (!ifMatched &&
conditionalSchemas.ElseSchema is not null &&
!TryMatchSchemaNode(
tableName,
yamlPath,
displayPath,
mappingNode,
conditionalSchemas.ElseSchema,
references,
allowUnknownObjectProperties: true))
{
var allOfEntryNumber = index + 1;
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must satisfy the 'else' schema because the inline 'if' condition did not match.",
$"{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:
"Conditional schema: the current object did not match the inline 'if' schema, so it must satisfy the corresponding 'else' schema.");
$"allOf entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} must match the current object.");
}
}
@ -3440,21 +3379,6 @@ 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);
}
}
}
/// <summary>
@ -3593,33 +3517,6 @@ internal static partial class YamlConfigSchemaValidator
return string.IsNullOrWhiteSpace(parentPath) ? propertyName : $"{parentPath}.{propertyName}";
}
/// <summary>
/// 返回当前节点上声明的“仅对象可用”关键字名称。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <returns>命中的关键字名称;未命中时返回空。</returns>
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;
}
/// <summary>
/// 判断当前标量是否应按字符串处理。
/// 这里显式排除 YAML 的数字、布尔和 null 标签,避免未加引号的值被当成字符串混入运行时。
@ -4088,21 +3985,18 @@ internal sealed class YamlConfigObjectConstraints
/// <param name="dependentRequired">对象内字段依赖约束。</param>
/// <param name="dependentSchemas">对象内条件 schema 约束。</param>
/// <param name="allOfSchemas">对象内组合 schema 约束。</param>
/// <param name="conditionalSchemas">对象内条件分支约束。</param>
public YamlConfigObjectConstraints(
int? minProperties,
int? maxProperties,
IReadOnlyDictionary<string, IReadOnlyList<string>>? dependentRequired,
IReadOnlyDictionary<string, YamlConfigSchemaNode>? dependentSchemas,
IReadOnlyList<YamlConfigSchemaNode>? allOfSchemas,
YamlConfigConditionalSchemas? conditionalSchemas)
IReadOnlyList<YamlConfigSchemaNode>? allOfSchemas)
{
MinProperties = minProperties;
MaxProperties = maxProperties;
DependentRequired = dependentRequired;
DependentSchemas = dependentSchemas;
AllOfSchemas = allOfSchemas;
ConditionalSchemas = conditionalSchemas;
}
/// <summary>
@ -4132,52 +4026,6 @@ internal sealed class YamlConfigObjectConstraints
/// 每个条目都表示“当前对象还必须额外满足的 focused constraint block”。
/// </summary>
public IReadOnlyList<YamlConfigSchemaNode>? AllOfSchemas { get; }
/// <summary>
/// 获取对象内 object-focused <c>if</c> / <c>then</c> / <c>else</c> 条件约束。
/// 该模型会先用 <c>if</c> 试匹配当前对象,再只对命中的分支叠加 focused constraint block。
/// </summary>
public YamlConfigConditionalSchemas? ConditionalSchemas { get; }
}
/// <summary>
/// 表示一个对象节点上声明的 object-focused <c>if</c> / <c>then</c> / <c>else</c> 条件约束。
/// 三个分支都共享父对象已声明字段集合,不会把分支 schema 扩展成新的生成类型形状。
/// </summary>
internal sealed class YamlConfigConditionalSchemas
{
/// <summary>
/// 初始化条件分支约束模型。
/// </summary>
/// <param name="ifSchema">条件判断 schema。</param>
/// <param name="thenSchema">条件命中时需要满足的 schema。</param>
/// <param name="elseSchema">条件未命中时需要满足的 schema。</param>
public YamlConfigConditionalSchemas(
YamlConfigSchemaNode ifSchema,
YamlConfigSchemaNode? thenSchema,
YamlConfigSchemaNode? elseSchema)
{
ArgumentNullException.ThrowIfNull(ifSchema);
IfSchema = ifSchema;
ThenSchema = thenSchema;
ElseSchema = elseSchema;
}
/// <summary>
/// 获取条件判断 schema。
/// </summary>
public YamlConfigSchemaNode IfSchema { get; }
/// <summary>
/// 获取条件命中时需要满足的 schema。
/// </summary>
public YamlConfigSchemaNode? ThenSchema { get; }
/// <summary>
/// 获取条件未命中时需要满足的 schema。
/// </summary>
public YamlConfigSchemaNode? ElseSchema { get; }
}
/// <summary>

View File

@ -1267,491 +1267,6 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证 object-focused <c>if</c> / <c>then</c> / <c>else</c> 会写入生成 XML 文档。
/// </summary>
[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 }."));
}
/// <summary>
/// 验证缺少 <c>if</c> 时生成器会拒绝孤立的 <c>then</c>。
/// </summary>
[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'"));
});
}
/// <summary>
/// 验证缺少 <c>if</c> 时生成器也会拒绝孤立的 <c>else</c>。
/// </summary>
[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'"));
});
}
/// <summary>
/// 验证只声明 <c>if</c> 而没有分支时,生成器会给出对齐运行时的诊断。
/// </summary>
[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'"));
});
}
/// <summary>
/// 验证条件分支不是 object schema 时,诊断路径会定位到具体分支而不是父对象。
/// </summary>
[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"));
});
}
/// <summary>
/// 验证条件分支不能引用父对象未声明的字段。
/// </summary>
[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"));
});
}
/// <summary>
/// 验证 <c>then</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。
/// </summary>
[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'."));
});
}
/// <summary>
/// 验证 <c>else</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。
/// </summary>
[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'."));
});
}
/// <summary>
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
/// </summary>

View File

@ -745,109 +745,6 @@ public class CqrsHandlerRegistryGeneratorTests
("CqrsHandlerRegistry.g.cs", HiddenGenericEnvelopeResponseExpected));
}
/// <summary>
/// 验证当 handler 合同把 pointer 响应类型放进 CQRS 泛型参数时,
/// 生成器会保守回退而不是继续发射不可构造的精确注册代码。
/// </summary>
[Test]
public void Reports_Compilation_Error_And_Skips_Precise_Registration_For_Hidden_Pointer_Response()
{
const string source = """
using System;
namespace Microsoft.Extensions.DependencyInjection
{
public interface IServiceCollection { }
public static class ServiceCollectionServiceExtensions
{
public static void AddTransient(IServiceCollection services, Type serviceType, Type implementationType) { }
}
}
namespace GFramework.Core.Abstractions.Logging
{
public interface ILogger
{
void Debug(string msg);
}
}
namespace GFramework.Cqrs.Abstractions.Cqrs
{
public interface IRequest<TResponse> { }
public interface INotification { }
public interface IStreamRequest<TResponse> { }
public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse> { }
public interface INotificationHandler<in TNotification> where TNotification : INotification { }
public interface IStreamRequestHandler<in TRequest, out TResponse> where TRequest : IStreamRequest<TResponse> { }
}
namespace GFramework.Cqrs
{
public interface ICqrsHandlerRegistry
{
void Register(Microsoft.Extensions.DependencyInjection.IServiceCollection services, GFramework.Core.Abstractions.Logging.ILogger logger);
}
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class CqrsHandlerRegistryAttribute : Attribute
{
public CqrsHandlerRegistryAttribute(Type registryType) { }
}
}
namespace TestApp
{
using GFramework.Cqrs.Abstractions.Cqrs;
public sealed class Container
{
private unsafe struct HiddenResponse
{
}
private unsafe sealed record HiddenRequest() : IRequest<HiddenResponse*>;
public unsafe sealed class HiddenHandler : IRequestHandler<HiddenRequest, HiddenResponse*>
{
}
}
}
""";
var execution = ExecuteGenerator(
source,
allowUnsafe: true);
var inputCompilationErrors = execution.InputCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatedCompilationErrors = execution.GeneratedCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatorErrors = execution.GeneratorDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var missingContractDiagnostic =
generatorErrors.SingleOrDefault(static diagnostic =>
string.Equals(diagnostic.Id, "GF_Cqrs_001", StringComparison.Ordinal));
Assert.Multiple(() =>
{
Assert.That(inputCompilationErrors.Select(static diagnostic => diagnostic.Id), Does.Contain("CS0306"));
Assert.That(generatedCompilationErrors, Is.Empty);
Assert.That(execution.GeneratedSources, Is.Empty);
Assert.That(missingContractDiagnostic, Is.Not.Null);
Assert.That(
missingContractDiagnostic!.GetMessage(),
Does.Contain("TestApp.Container+HiddenHandler"));
Assert.That(
missingContractDiagnostic.GetMessage(),
Does.Contain("GFramework.Cqrs.CqrsReflectionFallbackAttribute"));
});
}
/// <summary>
/// 验证同一个 implementation 同时包含可直接注册接口与需精确重建接口时,
/// 生成器会保留两类注册,并继续按 handler interface 名称稳定排序。
@ -1335,9 +1232,9 @@ public class CqrsHandlerRegistryGeneratorTests
{
}
private unsafe sealed record HiddenRequest() : IRequest<delegate* unmanaged<HiddenResponse>>;
private unsafe sealed record HiddenRequest() : IRequest<HiddenResponse*>;
public unsafe sealed class HiddenHandler : IRequestHandler<HiddenRequest, delegate* unmanaged<HiddenResponse>>
public unsafe sealed class HiddenHandler : IRequestHandler<HiddenRequest, HiddenResponse*>
{
}
}
@ -1347,9 +1244,6 @@ public class CqrsHandlerRegistryGeneratorTests
var execution = ExecuteGenerator(
source,
allowUnsafe: true);
var inputCompilationErrors = execution.InputCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatedCompilationErrors = execution.GeneratedCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
@ -1357,12 +1251,10 @@ public class CqrsHandlerRegistryGeneratorTests
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var missingContractDiagnostic =
generatorErrors.SingleOrDefault(static diagnostic =>
string.Equals(diagnostic.Id, "GF_Cqrs_001", StringComparison.Ordinal));
generatorErrors.SingleOrDefault(static diagnostic => diagnostic.Id == "GF_Cqrs_001");
Assert.Multiple(() =>
{
Assert.That(inputCompilationErrors.Select(static diagnostic => diagnostic.Id), Does.Contain("CS0306"));
Assert.That(generatedCompilationErrors, Is.Empty);
Assert.That(execution.GeneratedSources, Is.Empty);
Assert.That(missingContractDiagnostic, Is.Not.Null);
@ -1449,15 +1341,15 @@ public class CqrsHandlerRegistryGeneratorTests
{
}
private unsafe sealed record AlphaRequest() : IRequest<delegate* unmanaged<AlphaResponse>>;
private unsafe sealed record AlphaRequest() : IRequest<AlphaResponse*>;
private unsafe sealed record BetaRequest() : IRequest<delegate* unmanaged<BetaResponse>>;
private unsafe sealed record BetaRequest() : IRequest<BetaResponse*>;
public unsafe sealed class BetaHandler : IRequestHandler<BetaRequest, delegate* unmanaged<BetaResponse>>
public unsafe sealed class BetaHandler : IRequestHandler<BetaRequest, BetaResponse*>
{
}
public unsafe sealed class AlphaHandler : IRequestHandler<AlphaRequest, delegate* unmanaged<AlphaResponse>>
public unsafe sealed class AlphaHandler : IRequestHandler<AlphaRequest, AlphaResponse*>
{
}
}
@ -1467,9 +1359,6 @@ public class CqrsHandlerRegistryGeneratorTests
var execution = ExecuteGenerator(
source,
allowUnsafe: true);
var inputCompilationErrors = execution.InputCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatedCompilationErrors = execution.GeneratedCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
@ -1479,7 +1368,6 @@ public class CqrsHandlerRegistryGeneratorTests
Assert.Multiple(() =>
{
Assert.That(inputCompilationErrors.Select(static diagnostic => diagnostic.Id), Does.Contain("CS0306"));
Assert.That(generatedCompilationErrors, Is.Empty);
Assert.That(generatorErrors, Is.Empty);
Assert.That(execution.GeneratedSources, Has.Length.EqualTo(1));
@ -1592,11 +1480,6 @@ public class CqrsHandlerRegistryGeneratorTests
(filename: sourceResult.HintName, content: sourceResult.SourceText.ToString()))
.ToArray();
var compilationDiagnostics = updatedCompilation.GetDiagnostics().ToArray();
var inputCompilationDiagnostics = compilationDiagnostics
.Where(diagnostic =>
diagnostic.Location.SourceTree is null ||
!generatedSyntaxTrees.Contains(diagnostic.Location.SourceTree))
.ToArray();
var generatedCompilationDiagnostics = compilationDiagnostics
.Where(diagnostic =>
diagnostic.Location.SourceTree is not null &&
@ -1606,7 +1489,6 @@ public class CqrsHandlerRegistryGeneratorTests
generatedSources,
generatorDiagnostics.ToArray(),
compilationDiagnostics,
inputCompilationDiagnostics,
generatedCompilationDiagnostics);
}
@ -1616,12 +1498,10 @@ public class CqrsHandlerRegistryGeneratorTests
/// <param name="GeneratedSources">本轮生成产生的源文件集合。</param>
/// <param name="GeneratorDiagnostics">生成器自身报告的诊断集合。</param>
/// <param name="CompilationDiagnostics">将生成结果并回编译后的完整编译诊断集合。</param>
/// <param name="InputCompilationDiagnostics">仅来自输入源文件的编译诊断集合。</param>
/// <param name="GeneratedCompilationDiagnostics">仅来自生成源文件的编译诊断集合。</param>
private sealed record GeneratorExecutionResult(
(string filename, string content)[] GeneratedSources,
Diagnostic[] GeneratorDiagnostics,
Diagnostic[] CompilationDiagnostics,
Diagnostic[] InputCompilationDiagnostics,
Diagnostic[] GeneratedCompilationDiagnostics);
}

View File

@ -37,8 +37,8 @@
- [x] 继续扩展最有价值的 JSON Schema 子集
- 原则:只做 Runtime / Generator / Tooling 三端都能稳定解释的关键字
- 已补齐:`enum`(当前覆盖标量、对象、数组节点,以及标量数组元素)、`const``not``pattern``format`(当前稳定子集:`date``date-time``duration``email``time``uri``uuid`)、`minItems``maxItems``exclusiveMinimum``exclusiveMaximum``multipleOf``uniqueItems``minProperties``maxProperties``dependentRequired``dependentSchemas``allOf`、object-focused `if` / `then` / `else`
- 当前产出运行时拒绝相关约束违规值VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf` `if` / `then` / `else` 当前收敛为 object-focused constraint block不做属性合并
- 已补齐:`enum`(当前覆盖标量、对象、数组节点,以及标量数组元素)、`const``not``pattern``format`(当前稳定子集:`date``date-time``duration``email``time``uri``uuid`)、`minItems``maxItems``exclusiveMinimum``exclusiveMaximum``multipleOf``uniqueItems``minProperties``maxProperties``dependentRequired``dependentSchemas``allOf`
- 当前产出运行时拒绝相关约束违规值VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf` 当前收敛为 object-focused constraint block不做属性合并
- [x] 评估可选只读索引能力
- 目标:为高频查询字段提供比 `All()` 线性扫描更强的读取体验
@ -75,7 +75,7 @@
1. 用 `GeneratedConfigCatalog` 继续补齐启动与诊断辅助
2. 补一条比 `Architecture.OnInitialize()` 更正式的模块化接入建议
当前状态:第 1 项和第 2 项已完成,`allOf` 与 object-focused `if` / `then` / `else` 也已补齐;下一步转到下一批仍不改变生成形状的组合关键字评估,或继续推进 VS Code 复杂编辑体验
当前状态:第 1 项和第 2 项已完成,`allOf` 也已补齐;下一步转到仍不改变生成形状的组合关键字评估(优先看 `if` / `then` / `else`,或继续推进 VS Code 复杂编辑体验
## 完成标准
@ -86,27 +86,27 @@
## 下次恢复点
- 在当前稳定 `format` 子集(`date``date-time``duration``email``time``uri``uuid`、object-focused `allOf` 与 object-focused `if` / `then` / `else` 之后,转到下一批仍不改变生成类型形状的关键字评估;仍然不要先回工具 UI
- 在当前稳定 `format` 子集(`date``date-time``duration``email``time``uri``uuid`以及 object-focused `allOf` 之后,转到下一批仍不改变生成类型形状的关键字评估;仍然不要先回工具 UI
- 恢复时优先检查:
- `GFramework.Game/Config/YamlConfigSchemaValidator.cs`
- `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs`
- `GFramework.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-003`
- 恢复点编号:`AI-FIRST-CONFIG-RP-002`
- 当前阶段:`C# Runtime + Source Generator + Consumer DX`
- 已知风险:
- 复杂关键字形状风险:下一批候选关键字若像标准 `oneOf` / `anyOf` 那样影响对象分支形状,可能破坏当前生成契约
- 语义一致性风险:`if` / `then` / `else` 在 Runtime / Generator / Tooling 三端语义不一致的风险,需要先验证是否能在不引入生成类型形状漂移的前提下落地
- 工具链非阻塞风险:将 VS Code 功能标为非阻塞后,可能导致 C# 主线补齐新关键字时缺少工具侧同步验证
- 组合关键字范围风险:`allOf``if` / `then` / `else` 已收敛为 object-focused constraint block未来新增组合关键字时需明确是否同样限制范围
- 复杂关键字回退风险:`allOf` 已收敛为 object-focused constraint block未来新增组合关键字时需明确是否同样限制范围
- 最近验证:
- 时间2026-04-20
- 内容:`bun run test``SchemaConfigGeneratorTests``YamlConfigLoaderIfThenElseTests`
- 时间2026-04-17
- 内容:截至该日期的历史跟踪与执行 trace 已归档到主题内归档目录
- 结果:通过
- 下一步:
1. 检查 `YamlConfigSchemaValidator.cs``SchemaConfigGenerator.cs``configValidation.js` 中当前已支持的关键字列表
2. 评估 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集
3. 若结论否定,选择下一批共享解释关键字而不是先回工具 UI
2. 评估 `if` / `then` / `else` 是否能在三端保持一致语义且不改变生成类型形状
3. 若结论否定,选择下一批共享解释关键字而不是先回工具 UI

View File

@ -7,24 +7,19 @@
## 当前恢复点
- 恢复点编号:`AI-FIRST-CONFIG-RP-003`
- 恢复点编号:`AI-FIRST-CONFIG-RP-002`
- 当前阶段:`C# Runtime + Source Generator + Consumer DX`
- 当前焦点:
- 已完成 object-focused `if` / `then` / `else`,继续评估下一批仍不改变生成类型形状的共享关键字
- 已完成 PR #262 的 CodeRabbit follow-up补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests
- 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema
- 在当前稳定 `format` 子集与 object-focused `allOf` 之后,继续评估仍不改变生成类型形状的下一批组合关键字
- 优先考察 `if` / `then` / `else` 是否能在 Runtime / Generator / Tooling 三端保持一致语义
- 继续把 VS Code 工具能力视为非阻塞项,不让复杂 UI 编辑器需求反过来拖慢 C# 主线
### 已知风险
- 组合关键字扩展风险:下一批候选关键字可能像标准 `oneOf` / `anyOf` 一样更容易引入生成类型形状漂移
- 缓解措施:延续 object-focused / focused matcher 约束,只接受三端都能稳定解释且不需要属性合并的子集
- 语义一致性风险:`if` / `then` / `else` 在 Runtime / Generator / Tooling 三端语义不一致的风险
- 缓解措施:先验证是否能在不引入生成类型形状漂移的前提下落地,若否则选择下一批共享解释关键字
- 工具链验证风险VS Code 与 CI / 发布管道验证覆盖不足
- 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 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 解析,剩余项只需等待本地修复推送后再复抓确认
- 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过
- 非阻塞项回退风险:将 VS Code 功能标为非阻塞但导致主线回退的风险
- 缓解措施C# 主线补齐新关键字时仍需在 `configValidation.js``extension.js` 中同步落地,只是不让复杂表单控件阻塞发布
@ -36,33 +31,7 @@
- `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`
- `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 远端
- `minProperties``maxProperties``dependentRequired``dependentSchemas``allOf`
- 当前最细粒度的下一阶段 backlog 保留在独立文件:
- `ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md`
@ -84,15 +53,9 @@
- `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. 提交并推送当前 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 子集;若仍会引入生成类型形状漂移,就直接跳过
1. 先检查 `GFramework.Game/Config/YamlConfigSchemaValidator.cs``GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs``tools/gframework-config-tool/src/configValidation.js`
2. 评估 `if` / `then` / `else` 是否能在不引入生成类型形状漂移的前提下落地
3. 若结论是否定,再选择下一批仍能共享解释的关键字,而不是先回到工具 UI 深挖

View File

@ -32,80 +32,4 @@
1. 从 `ai-first-config-system-csharp-experience-next.md` 读取当前 backlog而不是继续翻已完成历史
2. 先判断 `if` / `then` / `else` 是否满足“三端一致且不改变生成形状”的前提
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-20skill 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 入口保持精简,只记录当前恢复点、验证与下一步
3. 若不满足,直接回退到下一批收益更明确的共享关键字评估

View File

@ -7,17 +7,12 @@ CQRS 迁移与收敛。
## 当前恢复点
- 恢复点编号:`CQRS-REWRITE-RP-050`
- 恢复点编号:`CQRS-REWRITE-RP-044`
- 当前阶段:`Phase 8`
- 当前焦点:
- 当前功能历史已归档active 跟踪仅保留 `Phase 8` 主线的恢复入口
- 已完成 generated registry 激活路径收敛:`CqrsHandlerRegistrar` 现优先复用缓存工厂委托,避免重复 `ConstructorInfo.Invoke`
- 已补充私有无参构造 generated registry 的回归测试,确保兼容现有生成器产物
- 已修正 pointer / function pointer 泛型合同的错误覆盖:生成器不再为这两类类型发射 precise runtime type 重建代码
- 已补充非法 CQRS 泛型合同的输入诊断断言,明确 `CS0306` 与 fallback / diagnostic 路径的组合语义
- 已为 registrar 的 reflection 注册路径补充 handler-interface 元数据缓存,减少跨容器重复注册时的 `GetInterfaces()` 反射
- 已将 registrar 的重复映射判定从线性扫描 `IServiceCollection` 收敛为本地映射索引,减少 fallback 注册路径的重复查找
- 中期上继续 `Phase 8` 主线:参考 `ai-libs/Mediator`,继续扩大 generator 覆盖,并选择下一个收益明确的 dispatch / invoker 反射收敛点
- 短期上先处理 `PR #253` 的 latest head review thread 复核,确认当前本地修正是否已在远端收敛
- 中期上继续 `Phase 8` 主线:参考 `ai-libs/Mediator`,扩大 generator 覆盖、减少 dispatch/invoker 热路径反射,并继续收口 package / facade / 兼容层
## 当前状态摘要
@ -30,28 +25,11 @@ CQRS 迁移与收敛。
## 当前活跃事实
- `Phase 8` 仍是当前主线,不再回退到 `Phase 7`
- `2026-04-20` 已重新执行 `$gframework-pr-review`
- 当前分支对应 `PR #261`,状态为 `OPEN`
- latest reviewed commit 当前剩余 `1` 条 open CodeRabbit thread指向 `ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md``RP-047``RP-050` 的历史语义冲突
- 本地已同步修正该追踪歧义:`RP-047` 明确标注为已被 `RP-050` 覆盖,后续不得恢复 `MakePointerType()` precise registration
- 远端测试信号保持通过:最新 CTRF 汇总为 `2118/2118` passedMegaLinter 仅剩 `dotnet-format` restore failure 预警,当前未提供本地仍然成立的文件级格式问题
- `2026-04-20` 已完成一轮冷启动反射收敛:
- generated registry 类型首次分析后,会缓存一个可复用的激活工厂,而不是在后续容器注册时重复走 `ConstructorInfo.Invoke`
- 若运行环境不允许动态方法,仍保留原有的反射激活回退,避免阻塞 generated registry 路径
- `GFramework.Cqrs.Tests` 已补充“私有无参构造 registry 仍可激活”的回归覆盖
- `2026-04-20` 已完成一轮 generator 覆盖面扩展:
- `CqrsHandlerRegistryGenerator` 现会在 runtime type 建模入口直接拒绝 `IPointerTypeSymbol``IFunctionPointerTypeSymbol`
- `CanReferenceFromGeneratedRegistry` 不再递归判断 pointer / function pointer 的内部元素,而是统一返回 `false`
- 相关 source-generator 回归已改为区分输入源诊断与生成源诊断,避免把非法泛型合同误判为成功生成
- `2026-04-20` 已完成一轮 registrar reflection 路径收敛:
- `CqrsHandlerRegistrar` 现会按 `Type` 弱键缓存已筛选且排序好的 supported handler interface 列表
- 同一 handler 类型跨容器重复注册时,不再重复执行 `GetInterfaces()` 与支持接口筛选
- `GFramework.Cqrs.Tests` 已补充 registrar 静态缓存隔离与 supported interface 缓存复用回归
- `2026-04-20` 已完成一轮 registrar 去重路径收敛:
- `CqrsHandlerRegistrar` 现会在单次 reflection 注册流程开始时构建已注册 handler 映射索引
- 同一批注册中后续 duplicate handler mapping 不再重复线性扫描 `IServiceCollection`
- `GFramework.Cqrs.Tests` 已补充“程序集返回重复 handler 类型时仍只注册一份映射”的回归
- 当前主线优先级:
- 最近一轮功能恢复点是 `RP-043`
- tracking 顶部阶段与恢复建议已对齐到 `Phase 8`
- `$gframework-pr-review` 会在 open thread 中显式提醒“`Addressed in commit ...` 文案不等于线程已关闭”
- 若当前分支已推送,应优先重新执行 `$gframework-pr-review`,确认 PR `#253` 的 latest head review threads 是否已收敛
- 若 PR review 噪音已收敛,再回到以下主线优先级:
- generator 覆盖面继续扩大
- dispatch/invoker 反射占比继续下降
- package / facade / 兼容层继续收口
@ -72,21 +50,9 @@ CQRS 迁移与收敛。
- `RP-043` 之前的详细阶段记录、定向验证命令和阶段性决策均已移入主题内归档
- active 跟踪文件只保留当前恢复点、当前活跃事实、风险和下一步,避免 `boot` 在默认入口中重复扫描 1000+ 行历史 trace
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false`
- 结果:通过
- 备注:`63/63` 测试通过;当前沙箱限制了 MSBuild named pipe验证需在提权环境下运行
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~CqrsHandlerRegistryGeneratorTests"`
- 结果:通过
- 备注:`14/14` 测试通过;本轮覆盖 pointer / function pointer 合同拒绝、fallback 诊断与现有精确注册路径
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~Reports_Compilation_Error_And_Skips_Precise_Registration_For_Hidden_Pointer_Response|FullyQualifiedName~Reports_Diagnostic_And_Skips_Registry_When_Fallback_Metadata_Is_Required_But_Runtime_Contract_Lacks_Fallback_Attribute|FullyQualifiedName~Emits_Assembly_Level_Fallback_Metadata_When_Fallback_Is_Required_And_Runtime_Contract_Is_Available"`
- 结果:通过
- 备注:`3/3` 测试通过;本轮直接覆盖 PR #261 指向的 3 个 pointer / function pointer 回归场景
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~GFramework.Cqrs.Tests.Cqrs.CqrsHandlerRegistrarTests"`
- 结果:通过
- 备注:`11/11` 测试通过;本轮覆盖 registrar 的 supported handler interface 缓存与 duplicate mapping 去重路径
## 下一步
1. 继续 `Phase 8` 主线,优先再找一个收益明确的 generator 覆盖缺口或 dispatch / invoker 反射收敛点继续推进
2. 若继续文档主线,优先再扫 `docs/zh-CN/api-reference` 与教程入口页,补齐仍过时的 CQRS API / 命名空间表述
3. 若后续再出现新的 PR review 或 review thread 变化,再重新执行 `$gframework-pr-review` 作为独立验证步骤
1. 推送当前分支后重新执行 `$gframework-pr-review`,确认 `PR #253` 的 latest head review threads 是否已收敛
2. 若 PR review 已收敛,回到 `Phase 8` 主线,优先选择一个收益明确的反射收敛点继续推进
3. 若继续文档主线,优先再扫 `docs/zh-CN/api-reference` 与教程入口页,补齐仍过时的 CQRS API / 命名空间表述

View File

@ -1,67 +1,14 @@
# CQRS 重写迁移追踪
## 2026-04-20
## 2026-04-19
### 阶段:pointer / function pointer 泛型合同拒绝CQRS-REWRITE-RP-050
### 阶段:active 入口归档收口CQRS-REWRITE-RP-044
- 重新执行 `$gframework-pr-review` 后,确认当前分支对应 `PR #261`,状态仍为 `OPEN`
- latest reviewed commit 当前剩余 `1` 条 open CodeRabbit thread指向 `RP-047` 历史记录仍把 `MakePointerType()` precise registration 写成现行路径
- 本地核对后确认该评论有效:当前 pointer / function pointer 语义已由 `RP-050` 收敛为 fallback / diagnostic 路径,历史追踪必须显式标注 `RP-047` 已废弃,避免后续恢复时误回滚到旧方案
- 已在 `GFramework.Cqrs.SourceGenerators/Cqrs/CqrsHandlerRegistryGenerator.cs` 中收紧 `TryCreateRuntimeTypeReference``CanReferenceFromGeneratedRegistry`
- pointer / function pointer 现统一视为不可精确生成的 CQRS 泛型合同,生成器会保守回退到既有 fallback / diagnostic 路径,而不再发射运行时 `MakeGenericType(...)` 风险代码
- 已在 `GFramework.SourceGenerators.Tests/Cqrs/CqrsHandlerRegistryGeneratorTests.cs` 中补充输入源诊断分离,并将相关测试改为显式断言 `CS0306` 与 fallback / diagnostic 结果
- 已同步修正 `ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md``RP-047` 段落,明确其已被 `RP-050` 覆盖,且不得恢复 `MakePointerType()` precise registration
- 定向验证已通过:
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~Reports_Compilation_Error_And_Skips_Precise_Registration_For_Hidden_Pointer_Response|FullyQualifiedName~Reports_Diagnostic_And_Skips_Registry_When_Fallback_Metadata_Is_Required_But_Runtime_Contract_Lacks_Fallback_Attribute|FullyQualifiedName~Emits_Assembly_Level_Fallback_Metadata_When_Fallback_Is_Required_And_Runtime_Contract_Is_Available"`
- `3/3` passed
- 扩展验证已通过:
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~CqrsHandlerRegistryGeneratorTests"`
- `14/14` passed
### 阶段registrar duplicate mapping 索引收敛CQRS-REWRITE-RP-049
- 已将 `CqrsHandlerRegistrar` 的重复 handler mapping 判定从逐条线性扫描 `IServiceCollection` 收敛为单次构建的本地映射索引
- reflection fallback 或重复类型输入场景下,后续 duplicate mapping 判定改为 `HashSet` 命中,不再重复遍历已有服务描述符
- `GFramework.Cqrs.Tests/Cqrs/CqrsHandlerRegistrarTests.cs` 已补充“程序集枚举返回重复 handler 类型时仍只注册一份映射”的回归
- 定向验证已通过:
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~GFramework.Cqrs.Tests.Cqrs.CqrsHandlerRegistrarTests"`
- `11/11` passed
- 当前沙箱限制 MSBuild named pipe因此验证在提权环境下执行
### 阶段registrar handler-interface 反射缓存CQRS-REWRITE-RP-048
- 已在 `CqrsHandlerRegistrar` 中新增按 `Type` 弱键缓存的 supported handler interface 元数据reflection 注册路径现会复用已筛选且排序好的接口列表
- 同一 handler 类型跨容器重复注册时,不再重复执行 `GetInterfaces()` 与支持接口筛选;缓存仍保持卸载安全,不会长期钉住 collectible 类型
- `GFramework.Cqrs.Tests/Cqrs/CqrsHandlerRegistrarTests.cs` 已补充 registrar 静态缓存清理与 supported interface 缓存复用回归
- 定向验证已通过:
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~GFramework.Cqrs.Tests.Cqrs.CqrsHandlerRegistrarTests"`
- `10/10` passed
- 当前沙箱限制 MSBuild named pipe因此验证在提权环境下执行
### 阶段pointer precise runtime type 覆盖扩展CQRS-REWRITE-RP-047已由 RP-050 覆盖)
- 曾在 `CqrsHandlerRegistryGenerator` 中尝试补充 pointer 类型的 runtime type 递归建模与源码发射,计划通过 `MakePointerType()` 还原隐藏 pointer 响应类型
- 该方案后续已被 `RP-050` 明确废弃pointer / function pointer 不能作为 CQRS 泛型合同的 precise registration 输入,当前实现统一回到 fallback / diagnostic 路径,不能恢复到 `MakePointerType()` 精确注册
- 已同步收紧 function pointer 签名的可直接生成判定,只有当签名中的返回值与参数类型均可从 generated registry 安全引用时才走静态注册
- 已保留含隐藏类型 function pointer handler 的 fallback / 诊断回归覆盖,确保 pointer 支持扩展不会误删原有程序集级 fallback 契约边界
- 后续若需恢复当前 pointer / function pointer 行为,应以 `RP-050` 为权威记录,而不是继续沿用本阶段的旧设计假设
- 定向验证与 `CqrsHandlerRegistryGeneratorTests` 全组验证均已通过:
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~Generates_Precise_Service_Type_For_Hidden_Pointer_Response|FullyQualifiedName~Reports_Diagnostic_And_Skips_Registry_When_Fallback_Metadata_Is_Required_But_Runtime_Contract_Lacks_Fallback_Attribute|FullyQualifiedName~Emits_Assembly_Level_Fallback_Metadata_When_Fallback_Is_Required_And_Runtime_Contract_Is_Available"`
- `3/3` passed
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false --filter "FullyQualifiedName~CqrsHandlerRegistryGeneratorTests"`
- `14/14` passed
- 当前沙箱限制 MSBuild named pipe因此验证在提权环境下执行
### 阶段generated registry 激活反射收敛CQRS-REWRITE-RP-046
- 已在 `CqrsHandlerRegistrar` 中将 generated registry 的无参构造激活改为类型级缓存工厂
- 默认路径优先使用一次性动态方法直接创建 registry避免后续每次命中缓存仍走 `ConstructorInfo.Invoke`
- 若运行环境不允许动态方法,则保留原有反射激活回退,确保 generated registry 路径不因运行时限制失效
- 已补充“私有无参构造 generated registry 仍可激活”的回归测试,覆盖现有生成器产物兼容性
- 定向验证已通过:
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-restore -p:RestoreFallbackFolders= -m:1 -nodeReuse:false`
- `63/63` passed
- 当前沙箱限制 MSBuild named pipe因此验证在提权环境下执行
- 已将截至 `RP-043` 的详细实现历史、验证记录与阶段性 trace 迁入主题内归档
- active trace 现在只保留当前恢复点与下一步,避免默认 boot 入口继续读取 1400+ 行已完成历史
- 当前功能主线保持不变:
- 先复核 `PR #253` 的 latest head review threads 是否已收敛
- 再继续 `Phase 8` 的 generator / dispatch / package 收口工作
### Archive Context
@ -72,6 +19,6 @@
### 当前下一步
1. 回到 `Phase 8` 主线,优先选一个明确的 dispatch / invoker 反射缩减点继续推进
2. 若继续文档主线,优先补齐 `docs/zh-CN/api-reference` 与教程入口页中仍过时的 CQRS API / 命名空间表述
3. 若后续 review thread 或 PR 状态再次变化,再重新执行 `$gframework-pr-review` 复核远端信号
1. 推送当前分支后重新执行 `$gframework-pr-review`
2. 以 latest head review thread 状态和本地文件事实为准,确认 `RP-042` / `RP-043` 修正是否真正收敛
3. 若收敛完成,回到 `Phase 8` 主线,优先选一个明确的反射缩减点继续推进

View File

@ -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``if` / `then` / `else`
- 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`
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
@ -794,7 +794,6 @@ 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` 条目不会把新字段并回父对象。
@ -836,60 +835,6 @@ 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` 读取稳定字段,而不必解析消息文本:
@ -985,7 +930,7 @@ var hotReload = loader.EnableHotReload(
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-tableUI 中显示为 ref-table / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf / if / then / else` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-tableUI 中显示为 ref-table / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。

View File

@ -1129,11 +1129,6 @@ 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")
@ -1145,7 +1140,6 @@ 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",
@ -1157,9 +1151,6 @@ 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,
@ -1430,87 +1421,6 @@ 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<string, SchemaNode>} 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<string, SchemaNode>} 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<string, SchemaNode>} 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.
@ -1521,60 +1431,31 @@ function validateConditionalSchemaTargets(rawSchema, displayPath, keywordName, p
* @param {Record<string, SchemaNode>} properties Declared parent properties.
*/
function validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, properties) {
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<string, SchemaNode>} properties Declared parent properties.
*/
function validateDeclaredTargetReferences(rawSchema, displayPath, contextLabel, properties) {
if (!rawSchema || typeof rawSchema !== "object" || Array.isArray(rawSchema)) {
if (!rawAllOfSchema || typeof rawAllOfSchema !== "object" || Array.isArray(rawAllOfSchema)) {
return;
}
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 (rawAllOfSchema.properties &&
typeof rawAllOfSchema.properties === "object" &&
!Array.isArray(rawAllOfSchema.properties)) {
for (const propertyName of Object.keys(rawAllOfSchema.properties)) {
if (Object.prototype.hasOwnProperty.call(properties, propertyName)) {
continue;
}
throw new Error(
`Schema property '${displayPath}' declares property '${propertyName}' in ${contextLabel}, ` +
`Schema property '${displayPath}' declares property '${propertyName}' in 'allOf' entry #${index + 1}, ` +
"but that property is not declared in the parent object schema.");
}
}
if (rawSchema.required === undefined) {
if (!Array.isArray(rawAllOfSchema.required)) {
return;
}
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}.`);
for (const requiredProperty of rawAllOfSchema.required) {
if (typeof requiredProperty !== "string" || requiredProperty.trim().length === 0) {
continue;
}
if (Object.prototype.hasOwnProperty.call(properties, requiredProperty)) {
@ -1582,7 +1463,7 @@ function validateDeclaredTargetReferences(rawSchema, displayPath, contextLabel,
}
throw new Error(
`Schema property '${displayPath}' requires property '${requiredProperty}' in ${contextLabel}, ` +
`Schema property '${displayPath}' requires property '${requiredProperty}' in 'allOf' entry #${index + 1}, ` +
"but that property is not declared in the parent object schema.");
}
}
@ -2006,48 +1887,6 @@ 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 || "<root>"
});
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 || "<root>"
});
if (!reportedMessages.has(localizedMessage)) {
diagnostics.push({
severity: "error",
message: localizedMessage
});
reportedMessages.add(localizedMessage);
}
}
if (typeof schemaNode.minProperties === "number" &&
propertyCount < schemaNode.minProperties) {
diagnostics.push({
@ -2189,22 +2028,6 @@ 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;
@ -2622,8 +2445,6 @@ 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:
@ -2652,8 +2473,6 @@ 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:
@ -2682,8 +2501,6 @@ 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:
@ -2712,8 +2529,6 @@ 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:
@ -3437,9 +3252,6 @@ module.exports = {
* dependentRequired?: Record<string, string[]>,
* dependentSchemas?: Record<string, SchemaNode>,
* allOf?: SchemaNode[],
* ifSchema?: SchemaNode,
* thenSchema?: SchemaNode,
* elseSchema?: SchemaNode,
* title?: string,
* description?: string,
* defaultValue?: string,

View File

@ -1576,75 +1576,9 @@ 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<string, string[]>=} dependentRequired dependentRequired metadata.
* @property {Record<string, InlineObjectSchemaHint>=} dependentSchemas dependentSchemas metadata.
* @property {Array<InlineObjectSchemaHint>=} 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 {InlineObjectSchemaHint} schema Parsed inline schema metadata.
* @param {{type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}} schema Parsed inline schema metadata.
* @param {boolean} includeRequiredProperties Whether object `required` members should be surfaced.
* @returns {string} Localized summary.
*/
@ -1688,7 +1622,7 @@ function describeInlineSchemaForHint(schema, includeRequiredProperties = false)
/**
* Render human-facing metadata hints for one schema field.
*
* @param {PropertySchemaHint} propertySchema Property schema metadata.
* @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<string, string[]>, dependentSchemas?: Record<string, {type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}>, 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 {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.
@ -1798,24 +1732,6 @@ 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})));
}

View File

@ -137,8 +137,6 @@ 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.",
@ -148,7 +146,6 @@ 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}.",
@ -159,7 +156,6 @@ 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.",
@ -267,8 +263,6 @@ 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。",
@ -278,7 +272,6 @@ 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}。",
@ -289,7 +282,6 @@ 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} 个字符。",

View File

@ -2,7 +2,6 @@ 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",
@ -19,7 +18,6 @@ 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",

View File

@ -2775,335 +2775,6 @@ 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"]);
});

View File

@ -174,21 +174,3 @@ 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");
});