From 0f8bf077e45e9bec5967290fd80f4a52963eb46d Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:28:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor(source-generators):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=E9=85=8D=E7=BD=AE=E7=94=9F=E6=88=90=E5=99=A8=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E7=83=AD=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 SchemaConfigGenerator 的 schema 解析、属性解析与遍历阶段 - 拆分数组属性、约束文档和生成代码发射 helper 以降低 MA0051 基线 - 更新 analyzer warning reduction 恢复文档和验证记录 --- .../Config/SchemaConfigGenerator.cs | 1709 +++++++++++------ .../analyzer-warning-reduction-tracking.md | 22 +- .../analyzer-warning-reduction-trace.md | 27 + 3 files changed, 1177 insertions(+), 581 deletions(-) diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index ece23f07..d0926c57 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -95,148 +95,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator AdditionalText file, CancellationToken cancellationToken) { - SourceText? text; - try + if (!TryReadSchemaText(file, cancellationToken, out var text, out var diagnostic)) { - text = file.GetText(cancellationToken); - } - catch (Exception exception) - { - return SchemaParseResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidSchemaJson, - CreateFileLocation(file.Path), - Path.GetFileName(file.Path), - exception.Message)); - } - - if (text is null) - { - return SchemaParseResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidSchemaJson, - CreateFileLocation(file.Path), - Path.GetFileName(file.Path), - "File content could not be read.")); + return SchemaParseResult.FromDiagnostic(diagnostic!); } try { - using var document = JsonDocument.Parse(text.ToString()); - var root = document.RootElement; - if (!root.TryGetProperty("type", out var rootTypeElement) || - !string.Equals(rootTypeElement.GetString(), "object", StringComparison.Ordinal)) - { - return SchemaParseResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.RootObjectSchemaRequired, - CreateFileLocation(file.Path), - Path.GetFileName(file.Path))); - } - - if (!TryValidateStringFormatMetadataRecursively( - file.Path, - "", - root, - out var rootFormatDiagnostic)) - { - return SchemaParseResult.FromDiagnostic(rootFormatDiagnostic!); - } - - if (!TryValidateDependentRequiredMetadataRecursively( - file.Path, - "", - root, - out var dependentRequiredDiagnostic)) - { - return SchemaParseResult.FromDiagnostic(dependentRequiredDiagnostic!); - } - - if (!TryValidateDependentSchemasMetadataRecursively( - file.Path, - "", - root, - out var dependentSchemasDiagnostic)) - { - return SchemaParseResult.FromDiagnostic(dependentSchemasDiagnostic!); - } - - if (!TryValidateAllOfMetadataRecursively( - file.Path, - "", - root, - out var allOfDiagnostic)) - { - return SchemaParseResult.FromDiagnostic(allOfDiagnostic!); - } - - if (!TryValidateConditionalSchemasMetadataRecursively( - file.Path, - "", - root, - out var conditionalDiagnostic)) - { - return SchemaParseResult.FromDiagnostic(conditionalDiagnostic!); - } - - var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); - var rootObject = ParseObjectSpec( - file.Path, - root, - "", - $"{entityName}Config", - isRoot: true); - if (rootObject.Diagnostic is not null) - { - return SchemaParseResult.FromDiagnostic(rootObject.Diagnostic); - } - - var schemaObject = rootObject.Object!; - var idProperty = schemaObject.Properties.FirstOrDefault(static property => - string.Equals(property.SchemaName, "id", StringComparison.OrdinalIgnoreCase)); - if (idProperty is null || !idProperty.IsRequired) - { - return SchemaParseResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.IdPropertyRequired, - CreateFileLocation(file.Path), - Path.GetFileName(file.Path))); - } - - if (!IsSchemaType(idProperty.TypeSpec.SchemaType, "integer") && - !IsSchemaType(idProperty.TypeSpec.SchemaType, "string")) - { - return SchemaParseResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.UnsupportedKeyType, - CreateFileLocation(file.Path), - Path.GetFileName(file.Path), - idProperty.TypeSpec.SchemaType)); - } - - var schemaBaseName = GetSchemaBaseName(file.Path); - var configRelativePath = ResolveConfigRelativePath(file.Path, root, schemaBaseName); - if (configRelativePath.Diagnostic is not null) - { - return SchemaParseResult.FromDiagnostic(configRelativePath.Diagnostic); - } - - var schema = new SchemaFileSpec( - Path.GetFileName(file.Path), - entityName, - schemaObject.ClassName, - $"{entityName}Table", - GeneratedNamespace, - idProperty.TypeSpec.ClrType.TrimEnd('?'), - idProperty.PropertyName, - schemaBaseName, - configRelativePath.Path!, - GetSchemaRelativePath(file.Path), - TryGetMetadataString(root, "title"), - TryGetMetadataString(root, "description"), - schemaObject); - - return SchemaParseResult.FromSchema(schema); + using var document = JsonDocument.Parse(text!.ToString()); + return ParseSchemaRoot(file.Path, document.RootElement); } catch (JsonException exception) { @@ -249,6 +116,193 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + /// + /// Reads an AdditionalFiles schema text while converting all IO failures into generator diagnostics. + /// + /// AdditionalFiles entry supplied by Roslyn. + /// Cancellation token forwarded by the incremental generator pipeline. + /// Read source text when the file can be loaded. + /// Diagnostic describing the read failure. + /// when schema text was read successfully; otherwise . + private static bool TryReadSchemaText( + AdditionalText file, + CancellationToken cancellationToken, + out SourceText? text, + out Diagnostic? diagnostic) + { + try + { + text = file.GetText(cancellationToken); + } + catch (Exception exception) + { + text = null; + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + exception.Message); + return false; + } + + if (text is null) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidSchemaJson, + CreateFileLocation(file.Path), + Path.GetFileName(file.Path), + "File content could not be read."); + return false; + } + + diagnostic = null; + return true; + } + + /// + /// Parses a JSON schema root after JSON syntax has already been validated. + /// + /// Schema file path used for diagnostics and generated metadata. + /// Parsed root JSON element. + /// Parsed schema model or the first schema diagnostic encountered. + private static SchemaParseResult ParseSchemaRoot(string filePath, JsonElement root) + { + if (!TryValidateSchemaRoot(filePath, root, out var diagnostic)) + { + return SchemaParseResult.FromDiagnostic(diagnostic!); + } + + var entityName = ToPascalCase(GetSchemaBaseName(filePath)); + var rootObject = ParseObjectSpec(filePath, root, "", $"{entityName}Config", isRoot: true); + if (rootObject.Diagnostic is not null) + { + return SchemaParseResult.FromDiagnostic(rootObject.Diagnostic); + } + + var schemaObject = rootObject.Object!; + var idProperty = FindValidIdProperty(filePath, schemaObject, out diagnostic); + if (diagnostic is not null) + { + return SchemaParseResult.FromDiagnostic(diagnostic); + } + + var schemaBaseName = GetSchemaBaseName(filePath); + var configRelativePath = ResolveConfigRelativePath(filePath, root, schemaBaseName); + if (configRelativePath.Diagnostic is not null) + { + return SchemaParseResult.FromDiagnostic(configRelativePath.Diagnostic); + } + + return SchemaParseResult.FromSchema(CreateSchemaFileSpec( + filePath, + root, + entityName, + schemaObject, + idProperty!, + schemaBaseName, + configRelativePath.Path!)); + } + + /// + /// Validates schema-level contracts that must hold before object and property models are built. + /// + /// Schema file path used for diagnostics. + /// Root JSON schema element. + /// First validation diagnostic, if any. + /// when the root can be parsed as a config schema. + private static bool TryValidateSchemaRoot(string filePath, JsonElement root, out Diagnostic? diagnostic) + { + if (!root.TryGetProperty("type", out var rootTypeElement) || + !IsSchemaType(rootTypeElement.GetString() ?? string.Empty, "object")) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.RootObjectSchemaRequired, + CreateFileLocation(filePath), + Path.GetFileName(filePath)); + return false; + } + + return TryValidateStringFormatMetadataRecursively(filePath, "", root, out diagnostic) && + TryValidateDependentRequiredMetadataRecursively(filePath, "", root, out diagnostic) && + TryValidateDependentSchemasMetadataRecursively(filePath, "", root, out diagnostic) && + TryValidateAllOfMetadataRecursively(filePath, "", root, out diagnostic) && + TryValidateConditionalSchemasMetadataRecursively(filePath, "", root, out diagnostic); + } + + /// + /// Finds and validates the required root id property that becomes the generated table key. + /// + /// Schema file path used for diagnostics. + /// Parsed root object model. + /// Diagnostic explaining why the key is invalid. + /// The key property when it satisfies the generator contract; otherwise null. + private static SchemaPropertySpec? FindValidIdProperty( + string filePath, + SchemaObjectSpec schemaObject, + out Diagnostic? diagnostic) + { + var idProperty = schemaObject.Properties.FirstOrDefault(static property => + string.Equals(property.SchemaName, "id", StringComparison.OrdinalIgnoreCase)); + if (idProperty is null || !idProperty.IsRequired) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.IdPropertyRequired, + CreateFileLocation(filePath), + Path.GetFileName(filePath)); + return null; + } + + if (!IsSchemaType(idProperty.TypeSpec.SchemaType, "integer") && + !IsSchemaType(idProperty.TypeSpec.SchemaType, "string")) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedKeyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + idProperty.TypeSpec.SchemaType); + return null; + } + + diagnostic = null; + return idProperty; + } + + /// + /// Creates the generator-level schema model from validated root metadata and the parsed object tree. + /// + /// Schema file path. + /// Root JSON element used for optional title and description metadata. + /// Generated entity name derived from the schema file name. + /// Parsed root object model. + /// Validated required key property. + /// Normalized schema base name. + /// Resolved config-relative directory path. + /// Completed schema file model used by source emission. + private static SchemaFileSpec CreateSchemaFileSpec( + string filePath, + JsonElement root, + string entityName, + SchemaObjectSpec schemaObject, + SchemaPropertySpec idProperty, + string schemaBaseName, + string configRelativePath) + { + return new SchemaFileSpec( + Path.GetFileName(filePath), + entityName, + schemaObject.ClassName, + $"{entityName}Table", + GeneratedNamespace, + idProperty.TypeSpec.ClrType.TrimEnd('?'), + idProperty.PropertyName, + schemaBaseName, + configRelativePath, + GetSchemaRelativePath(filePath), + TryGetMetadataString(root, "title"), + TryGetMetadataString(root, "description"), + schemaObject); + } + /// /// 解析对象 schema,并递归构建子属性模型。 /// @@ -347,6 +401,45 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } var schemaType = typeElement.GetString() ?? string.Empty; + if (!TryCreatePropertyParseContext( + filePath, + property, + isRequired, + displayPath, + isDirectChildOfRoot, + schemaType, + out var context, + out var diagnostic)) + { + return ParsedPropertyResult.FromDiagnostic(diagnostic!); + } + + return CreatePropertyBySchemaType(filePath, property, schemaType, context!); + } + + /// + /// Collects shared property metadata and validates cross-cutting metadata before type-specific parsing begins. + /// + /// Schema file path used for diagnostics. + /// Schema property JSON node. + /// Whether the property is required. + /// Logical schema path. + /// Whether the property is a direct child of the schema root. + /// Validated schema type keyword. + /// Shared parsing metadata when validation succeeds. + /// First metadata validation diagnostic. + /// when the property can continue to type-specific parsing. + private static bool TryCreatePropertyParseContext( + string filePath, + JsonProperty property, + bool isRequired, + string displayPath, + bool isDirectChildOfRoot, + string schemaType, + out PropertyParseContext? context, + out Diagnostic? diagnostic) + { + context = null; var title = TryGetMetadataString(property.Value, "title"); var description = TryGetMetadataString(property.Value, "description"); var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table"); @@ -357,26 +450,27 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator schemaType, out var formatDiagnostic)) { - return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!); + diagnostic = formatDiagnostic; + return false; } var indexedLookupMetadata = TryGetMetadataBoolean(property.Value, LookupIndexMetadataKey); if (indexedLookupMetadata.Diagnostic is not null) { - return ParsedPropertyResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - LookupIndexMetadataKey, - indexedLookupMetadata.Diagnostic!)); + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + LookupIndexMetadataKey, + indexedLookupMetadata.Diagnostic!); + return false; } var isIndexedLookup = indexedLookupMetadata.Value ?? false; - if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out var diagnostic)) + if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out diagnostic)) { - return ParsedPropertyResult.FromDiagnostic(diagnostic!); + return false; } if (isIndexedLookup && @@ -389,154 +483,147 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator refTableName, out diagnostic)) { - return ParsedPropertyResult.FromDiagnostic(diagnostic!); + return false; } - switch (schemaType) + context = new PropertyParseContext( + displayPath, + propertyName, + isRequired, + title, + description, + refTableName, + isIndexedLookup); + diagnostic = null; + return true; + } + + /// + /// Dispatches schema property parsing by JSON schema type after shared validation and metadata extraction. + /// + /// Schema file path used for diagnostics. + /// Schema property JSON node. + /// Validated schema type keyword. + /// Shared parsing metadata collected from the property node. + /// Parsed property model or an unsupported-type diagnostic. + private static ParsedPropertyResult CreatePropertyBySchemaType( + string filePath, + JsonProperty property, + string schemaType, + PropertyParseContext context) + { + return schemaType switch { - case "integer": - return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( - property.Name, - displayPath, - propertyName, - isRequired, - title, - description, - isIndexedLookup, - new SchemaTypeSpec( - SchemaNodeKind.Scalar, - "integer", - isRequired ? "int" : "int?", - TryBuildScalarInitializer(property.Value, "integer"), - TryBuildEnumDocumentation(property.Value, "integer"), - TryBuildConstraintDocumentation(property.Value, "integer"), - refTableName, - null, - null))); + "integer" => CreateScalarPropertyResult(property, context, schemaType, context.IsRequired ? "int" : "int?"), + "number" => CreateScalarPropertyResult(property, context, schemaType, context.IsRequired ? "double" : "double?"), + "boolean" => CreateScalarPropertyResult(property, context, schemaType, context.IsRequired ? "bool" : "bool?"), + "string" => CreateScalarPropertyResult(property, context, schemaType, context.IsRequired ? "string" : "string?"), + "object" => CreateObjectPropertyResult(filePath, property, context), + "array" => ParseArrayProperty(filePath, property, context), + _ => ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + context.DisplayPath, + schemaType)), + }; + } - case "number": - return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( - property.Name, - displayPath, - propertyName, - isRequired, - title, - description, - isIndexedLookup, - new SchemaTypeSpec( - SchemaNodeKind.Scalar, - "number", - isRequired ? "double" : "double?", - TryBuildScalarInitializer(property.Value, "number"), - TryBuildEnumDocumentation(property.Value, "number"), - TryBuildConstraintDocumentation(property.Value, "number"), - refTableName, - null, - null))); - - case "boolean": - return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( - property.Name, - displayPath, - propertyName, - isRequired, - title, - description, - isIndexedLookup, - new SchemaTypeSpec( - SchemaNodeKind.Scalar, - "boolean", - isRequired ? "bool" : "bool?", - TryBuildScalarInitializer(property.Value, "boolean"), - TryBuildEnumDocumentation(property.Value, "boolean"), - TryBuildConstraintDocumentation(property.Value, "boolean"), - refTableName, - null, - null))); - - case "string": - return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( - property.Name, - displayPath, - propertyName, - isRequired, - title, - description, - isIndexedLookup, - new SchemaTypeSpec( - SchemaNodeKind.Scalar, - "string", - isRequired ? "string" : "string?", - TryBuildScalarInitializer(property.Value, "string") ?? - (isRequired ? " = string.Empty;" : null), - TryBuildEnumDocumentation(property.Value, "string"), - TryBuildConstraintDocumentation(property.Value, "string"), - refTableName, - null, - null))); - - case "object": - if (isIndexedLookup) - { - return ParsedPropertyResult.FromDiagnostic( - CreateInvalidLookupIndexDiagnostic(filePath, displayPath, - LookupIndexTopLevelScalarOnlyMessage)); - } - - if (!string.IsNullOrWhiteSpace(refTableName)) - { - return ParsedPropertyResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.UnsupportedPropertyType, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - "object-ref")); - } - - var objectResult = ParseObjectSpec( - filePath, - property.Value, - displayPath, - $"{propertyName}Config"); - if (objectResult.Diagnostic is not null) - { - return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic); - } - - var objectSpec = objectResult.Object!; - return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( - property.Name, - displayPath, - propertyName, - isRequired, - title, - description, - false, - new SchemaTypeSpec( - SchemaNodeKind.Object, - "object", - isRequired ? objectSpec.ClassName : $"{objectSpec.ClassName}?", - isRequired ? " = new();" : null, - TryBuildEnumDocumentation(property.Value, "object"), - null, - null, - objectSpec, - null))); - - case "array": - return ParseArrayProperty(filePath, property, isRequired, displayPath, propertyName, title, - description, refTableName, isIndexedLookup); - - default: - return ParsedPropertyResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.UnsupportedPropertyType, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - schemaType)); + /// + /// Builds a parsed scalar property model while preserving schema metadata used by generated XML docs. + /// + /// Schema property JSON node. + /// Shared parsing metadata collected from the property node. + /// Scalar schema type. + /// Generated CLR property type. + /// Parsed property result for a scalar schema node. + private static ParsedPropertyResult CreateScalarPropertyResult( + JsonProperty property, + PropertyParseContext context, + string schemaType, + string clrType) + { + var initializer = TryBuildScalarInitializer(property.Value, schemaType); + if (IsSchemaType(schemaType, "string") && initializer is null && context.IsRequired) + { + initializer = " = string.Empty;"; } + + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + context.DisplayPath, + context.PropertyName, + context.IsRequired, + context.Title, + context.Description, + context.IsIndexedLookup, + new SchemaTypeSpec( + SchemaNodeKind.Scalar, + schemaType, + clrType, + initializer, + TryBuildEnumDocumentation(property.Value, schemaType), + TryBuildConstraintDocumentation(property.Value, schemaType), + context.RefTableName, + null, + null))); + } + + /// + /// Builds a parsed object property model and reports unsupported object reference/index combinations. + /// + /// Schema file path used for diagnostics. + /// Schema property JSON node. + /// Shared parsing metadata collected from the property node. + /// Parsed property result for an object schema node. + private static ParsedPropertyResult CreateObjectPropertyResult( + string filePath, + JsonProperty property, + PropertyParseContext context) + { + if (context.IsIndexedLookup) + { + return ParsedPropertyResult.FromDiagnostic( + CreateInvalidLookupIndexDiagnostic(filePath, context.DisplayPath, LookupIndexTopLevelScalarOnlyMessage)); + } + + if (!string.IsNullOrWhiteSpace(context.RefTableName)) + { + return ParsedPropertyResult.FromDiagnostic( + Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + context.DisplayPath, + "object-ref")); + } + + var objectResult = ParseObjectSpec(filePath, property.Value, context.DisplayPath, $"{context.PropertyName}Config"); + if (objectResult.Diagnostic is not null) + { + return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic); + } + + var objectSpec = objectResult.Object!; + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + context.DisplayPath, + context.PropertyName, + context.IsRequired, + context.Title, + context.Description, + false, + new SchemaTypeSpec( + SchemaNodeKind.Object, + "object", + context.IsRequired ? objectSpec.ClassName : $"{objectSpec.ClassName}?", + context.IsRequired ? " = new();" : null, + TryBuildEnumDocumentation(property.Value, "object"), + null, + null, + objectSpec, + null))); } /// @@ -738,157 +825,271 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } - if (string.Equals(schemaType, "object", StringComparison.Ordinal) && - element.TryGetProperty("properties", out var propertiesElement) && - propertiesElement.ValueKind == JsonValueKind.Object) - { - foreach (var property in propertiesElement.EnumerateObject()) - { - if (!TryTraverseSchemaRecursively( - filePath, - CombinePath(displayPath, property.Name), - property.Value, - nodeValidator, - out diagnostic)) - { - return false; - } - } - } - - if (string.Equals(schemaType, "object", StringComparison.Ordinal) && - element.TryGetProperty("dependentSchemas", out var dependentSchemasElement) && - dependentSchemasElement.ValueKind == JsonValueKind.Object) - { - foreach (var dependentSchema in dependentSchemasElement.EnumerateObject()) - { - if (dependentSchema.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - if (!TryTraverseSchemaRecursively( - filePath, - $"{displayPath}[dependentSchemas:{dependentSchema.Name}]", - dependentSchema.Value, - nodeValidator, - out diagnostic)) - { - return false; - } - } - } - - if (string.Equals(schemaType, "object", StringComparison.Ordinal) && - element.TryGetProperty("allOf", out var allOfElement) && - allOfElement.ValueKind == JsonValueKind.Array) - { - var allOfIndex = 0; - foreach (var allOfSchema in allOfElement.EnumerateArray()) - { - if (allOfSchema.ValueKind != JsonValueKind.Object) - { - allOfIndex++; - continue; - } - - if (!TryTraverseSchemaRecursively( - filePath, - BuildAllOfEntryPath(displayPath, allOfIndex), - allOfSchema, - nodeValidator, - out diagnostic)) - { - return false; - } - - allOfIndex++; - } - } - - 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( - filePath, - $"{displayPath}[not]", - notElement, - nodeValidator, - out diagnostic)) + if (IsSchemaType(schemaType, "object") && + !TryTraverseObjectChildren(filePath, displayPath, element, nodeValidator, out diagnostic)) { return false; } - if (!string.Equals(schemaType, "array", StringComparison.Ordinal)) + if (!TryTraverseSingleSchemaProperty(filePath, $"{displayPath}[not]", element, "not", nodeValidator, out diagnostic)) + { + return false; + } + + return !IsSchemaType(schemaType, "array") || + TryTraverseArrayChildren(filePath, displayPath, element, nodeValidator, out diagnostic); + } + + /// + /// Traverses every object-only child schema keyword in the same order as the parser observes properties. + /// + /// Schema file path. + /// Logical object path. + /// Object schema node. + /// Current validation callback. + /// First child validation diagnostic. + /// when all object child schemas pass validation. + private static bool TryTraverseObjectChildren( + string filePath, + string displayPath, + JsonElement element, + Func nodeValidator, + out Diagnostic? diagnostic) + { + if (!TryTraverseObjectProperties(filePath, displayPath, element, nodeValidator, out diagnostic)) + { + return false; + } + + if (!TryTraverseDependentSchemas(filePath, displayPath, element, nodeValidator, out diagnostic)) + { + return false; + } + + return TryTraverseAllOfEntries(filePath, displayPath, element, nodeValidator, out diagnostic) && + TryTraverseObjectSchemaBranches(filePath, displayPath, element, nodeValidator, out diagnostic); + } + + /// + /// Traverses nested object properties in declaration order. + /// + /// Schema file path. + /// Logical object path. + /// Object schema node. + /// Current validation callback. + /// First property validation diagnostic. + /// when every property subtree passes validation. + private static bool TryTraverseObjectProperties( + string filePath, + string displayPath, + JsonElement element, + Func nodeValidator, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) { return true; } - if (element.TryGetProperty("items", out var itemsElement) && - itemsElement.ValueKind == JsonValueKind.Object && - !TryTraverseSchemaRecursively( - filePath, - $"{displayPath}[]", - itemsElement, - nodeValidator, - out diagnostic)) + foreach (var property in propertiesElement.EnumerateObject()) { - return false; - } - - if (element.TryGetProperty("contains", out var containsElement) && - containsElement.ValueKind == JsonValueKind.Object && - !TryTraverseSchemaRecursively( - filePath, - $"{displayPath}[contains]", - containsElement, - nodeValidator, - out diagnostic)) - { - return false; + if (!TryTraverseSchemaRecursively( + filePath, + CombinePath(displayPath, property.Name), + property.Value, + nodeValidator, + out diagnostic)) + { + return false; + } } return true; } + /// + /// Traverses object dependentSchemas entries that are themselves schema objects. + /// + /// Schema file path. + /// Logical object path. + /// Object schema node. + /// Current validation callback. + /// First dependent schema validation diagnostic. + /// when every dependent schema subtree passes validation. + private static bool TryTraverseDependentSchemas( + string filePath, + string displayPath, + JsonElement element, + Func nodeValidator, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement) || + dependentSchemasElement.ValueKind != JsonValueKind.Object) + { + return true; + } + + foreach (var dependentSchema in dependentSchemasElement.EnumerateObject()) + { + if (dependentSchema.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!TryTraverseSchemaRecursively( + filePath, + $"{displayPath}[dependentSchemas:{dependentSchema.Name}]", + dependentSchema.Value, + nodeValidator, + out diagnostic)) + { + return false; + } + } + + return true; + } + + /// + /// Traverses object allOf entries while preserving their numeric path segments. + /// + /// Schema file path. + /// Logical object path. + /// Object schema node. + /// Current validation callback. + /// First allOf validation diagnostic. + /// when every object allOf entry passes validation. + private static bool TryTraverseAllOfEntries( + string filePath, + string displayPath, + JsonElement element, + Func nodeValidator, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("allOf", out var allOfElement) || + allOfElement.ValueKind != JsonValueKind.Array) + { + return true; + } + + var allOfIndex = 0; + foreach (var allOfSchema in allOfElement.EnumerateArray()) + { + if (allOfSchema.ValueKind != JsonValueKind.Object) + { + allOfIndex++; + continue; + } + + if (!TryTraverseSchemaRecursively( + filePath, + BuildAllOfEntryPath(displayPath, allOfIndex), + allOfSchema, + nodeValidator, + out diagnostic)) + { + return false; + } + + allOfIndex++; + } + + return true; + } + + /// + /// Traverses the object-focused conditional schema branches. + /// + /// Schema file path. + /// Logical object path. + /// Object schema node. + /// Current validation callback. + /// First branch validation diagnostic. + /// when all present conditional branches pass validation. + private static bool TryTraverseObjectSchemaBranches( + string filePath, + string displayPath, + JsonElement element, + Func nodeValidator, + out Diagnostic? diagnostic) + { + return TryTraverseSingleSchemaProperty( + filePath, + BuildConditionalSchemaPath(displayPath, "if"), + element, + "if", + nodeValidator, + out diagnostic) && + TryTraverseSingleSchemaProperty( + filePath, + BuildConditionalSchemaPath(displayPath, "then"), + element, + "then", + nodeValidator, + out diagnostic) && + TryTraverseSingleSchemaProperty( + filePath, + BuildConditionalSchemaPath(displayPath, "else"), + element, + "else", + nodeValidator, + out diagnostic); + } + + /// + /// Traverses a single child schema property when it is present and object-shaped. + /// + /// Schema file path. + /// Logical path assigned to the child schema. + /// Parent schema node. + /// Child schema keyword. + /// Current validation callback. + /// Child validation diagnostic. + /// when the child is absent or passes validation. + private static bool TryTraverseSingleSchemaProperty( + string filePath, + string childDisplayPath, + JsonElement element, + string propertyName, + Func nodeValidator, + out Diagnostic? diagnostic) + { + diagnostic = null; + return !element.TryGetProperty(propertyName, out var childElement) || + childElement.ValueKind != JsonValueKind.Object || + TryTraverseSchemaRecursively(filePath, childDisplayPath, childElement, nodeValidator, out diagnostic); + } + + /// + /// Traverses array items and contains child schemas. + /// + /// Schema file path. + /// Logical array path. + /// Array schema node. + /// Current validation callback. + /// First array child validation diagnostic. + /// when all array child schemas pass validation. + private static bool TryTraverseArrayChildren( + string filePath, + string displayPath, + JsonElement element, + Func nodeValidator, + out Diagnostic? diagnostic) + { + return TryTraverseSingleSchemaProperty(filePath, $"{displayPath}[]", element, "items", nodeValidator, out diagnostic) && + TryTraverseSingleSchemaProperty( + filePath, + $"{displayPath}[contains]", + element, + "contains", + nodeValidator, + out diagnostic); + } + /// /// 为对象级 allOf 条目生成与运行时一致的逻辑路径。 /// @@ -1880,157 +2081,235 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// /// Schema 文件路径。 /// 属性 JSON 节点。 - /// 属性是否必填。 - /// 逻辑字段路径。 - /// CLR 属性名。 - /// 标题元数据。 - /// 说明元数据。 - /// 目标引用表名称。 - /// 是否为索引查找。 + /// 属性解析共享上下文。 /// 解析后的属性信息或诊断。 private static ParsedPropertyResult ParseArrayProperty( string filePath, JsonProperty property, - bool isRequired, - string displayPath, - string propertyName, - string? title, - string? description, - string? refTableName, - bool isIndexedLookup) + PropertyParseContext context) { - if (isIndexedLookup) + if (context.IsIndexedLookup) { return ParsedPropertyResult.FromDiagnostic( - CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage)); + CreateInvalidLookupIndexDiagnostic(filePath, context.DisplayPath, LookupIndexTopLevelScalarOnlyMessage)); } - if (!property.Value.TryGetProperty("items", out var itemsElement) || - itemsElement.ValueKind != JsonValueKind.Object || - !itemsElement.TryGetProperty("type", out var itemTypeElement) || - itemTypeElement.ValueKind != JsonValueKind.String) + if (!TryGetArrayItemSchema(filePath, property, context.DisplayPath, out var itemsElement, out var itemType, out var diagnostic)) { - return ParsedPropertyResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.UnsupportedPropertyType, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - "array")); + return ParsedPropertyResult.FromDiagnostic(diagnostic!); } - var itemType = itemTypeElement.GetString() ?? string.Empty; - if (!TryValidateStringFormatMetadata(filePath, $"{displayPath}[]", itemsElement, itemType, + if (!TryValidateStringFormatMetadata(filePath, $"{context.DisplayPath}[]", itemsElement, itemType, out var formatDiagnostic)) { return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!); } - switch (itemType) + return itemType switch { - case "integer": - case "number": - case "boolean": - case "string": - var itemClrType = itemType switch - { - "integer" => "int", - "number" => "double", - "boolean" => "bool", - _ => "string" - }; + "integer" or "number" or "boolean" or "string" => + CreateScalarArrayPropertyResult(property, context, itemsElement, itemType), + "object" => CreateObjectArrayPropertyResult(filePath, property, context, itemsElement), + _ => ParsedPropertyResult.FromDiagnostic(CreateUnsupportedArrayItemDiagnostic( + filePath, + context.DisplayPath, + $"array<{itemType}>")), + }; + } - return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( - property.Name, - displayPath, - propertyName, - isRequired, - title, - description, - false, - new SchemaTypeSpec( - SchemaNodeKind.Array, - "array", - $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", - TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? - $" = global::System.Array.Empty<{itemClrType}>();", - TryBuildEnumDocumentation(property.Value, "array") ?? - TryBuildEnumDocumentation(itemsElement, itemType), - TryBuildConstraintDocumentation(property.Value, "array"), - refTableName, - null, - new SchemaTypeSpec( - SchemaNodeKind.Scalar, - itemType, - itemClrType, - null, - TryBuildEnumDocumentation(itemsElement, itemType), - TryBuildConstraintDocumentation(itemsElement, itemType), - refTableName, - null, - null)))); - - case "object": - if (!string.IsNullOrWhiteSpace(refTableName)) - { - return ParsedPropertyResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.UnsupportedPropertyType, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - "array-ref")); - } - - var objectResult = ParseObjectSpec( - filePath, - itemsElement, - $"{displayPath}[]", - $"{propertyName}ItemConfig"); - if (objectResult.Diagnostic is not null) - { - return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic); - } - - var objectSpec = objectResult.Object!; - return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( - property.Name, - displayPath, - propertyName, - isRequired, - title, - description, - false, - new SchemaTypeSpec( - SchemaNodeKind.Array, - "array", - $"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>", - $" = global::System.Array.Empty<{objectSpec.ClassName}>();", - TryBuildEnumDocumentation(property.Value, "array") ?? - TryBuildEnumDocumentation(itemsElement, "object"), - TryBuildConstraintDocumentation(property.Value, "array"), - null, - null, - new SchemaTypeSpec( - SchemaNodeKind.Object, - "object", - objectSpec.ClassName, - null, - TryBuildEnumDocumentation(itemsElement, "object"), - null, - null, - objectSpec, - null)))); - - default: - return ParsedPropertyResult.FromDiagnostic( - Diagnostic.Create( - ConfigSchemaDiagnostics.UnsupportedPropertyType, - CreateFileLocation(filePath), - Path.GetFileName(filePath), - displayPath, - $"array<{itemType}>")); + /// + /// Reads the items.type declaration required by the supported array schema subset. + /// + /// Schema file path used for diagnostics. + /// Array property JSON node. + /// Logical schema path. + /// Array item schema node when present. + /// Array item schema type when present. + /// Diagnostic explaining an invalid declaration. + /// when the array item schema is supported by the parser. + private static bool TryGetArrayItemSchema( + string filePath, + JsonProperty property, + string displayPath, + out JsonElement itemsElement, + out string itemType, + out Diagnostic? diagnostic) + { + if (property.Value.TryGetProperty("items", out itemsElement) && + itemsElement.ValueKind == JsonValueKind.Object && + itemsElement.TryGetProperty("type", out var itemTypeElement) && + itemTypeElement.ValueKind == JsonValueKind.String) + { + itemType = itemTypeElement.GetString() ?? string.Empty; + diagnostic = null; + return true; } + + itemType = string.Empty; + var itemDescription = "array"; + if (itemsElement.ValueKind == JsonValueKind.Object && + itemsElement.TryGetProperty("type", out var unsupportedTypeElement) && + unsupportedTypeElement.ValueKind == JsonValueKind.String) + { + itemDescription = $"array<{unsupportedTypeElement.GetString() ?? string.Empty}>"; + } + + diagnostic = CreateUnsupportedArrayItemDiagnostic(filePath, displayPath, itemDescription); + return false; + } + + /// + /// Builds an array property whose items are scalar values. + /// + /// Array property JSON node. + /// Property parsing context. + /// Array item schema node. + /// Array item scalar schema type. + /// Parsed array property model. + private static ParsedPropertyResult CreateScalarArrayPropertyResult( + JsonProperty property, + PropertyParseContext context, + JsonElement itemsElement, + string itemType) + { + var itemClrType = itemType switch + { + "integer" => "int", + "number" => "double", + "boolean" => "bool", + _ => "string" + }; + + return ParsedPropertyResult.FromProperty(new SchemaPropertySpec( + property.Name, + context.DisplayPath, + context.PropertyName, + context.IsRequired, + context.Title, + context.Description, + false, + new SchemaTypeSpec( + SchemaNodeKind.Array, + "array", + $"global::System.Collections.Generic.IReadOnlyList<{itemClrType}>", + TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? + $" = global::System.Array.Empty<{itemClrType}>();", + TryBuildEnumDocumentation(property.Value, "array") ?? + TryBuildEnumDocumentation(itemsElement, itemType), + TryBuildConstraintDocumentation(property.Value, "array"), + context.RefTableName, + null, + new SchemaTypeSpec( + SchemaNodeKind.Scalar, + itemType, + itemClrType, + null, + TryBuildEnumDocumentation(itemsElement, itemType), + TryBuildConstraintDocumentation(itemsElement, itemType), + context.RefTableName, + null, + null)))); + } + + /// + /// Builds an array property whose items are nested object values. + /// + /// Schema file path used for diagnostics. + /// Array property JSON node. + /// Property parsing context. + /// Array item schema node. + /// Parsed array property model or an object-array diagnostic. + private static ParsedPropertyResult CreateObjectArrayPropertyResult( + string filePath, + JsonProperty property, + PropertyParseContext context, + JsonElement itemsElement) + { + if (!string.IsNullOrWhiteSpace(context.RefTableName)) + { + return ParsedPropertyResult.FromDiagnostic( + CreateUnsupportedArrayItemDiagnostic(filePath, context.DisplayPath, "array-ref")); + } + + var objectResult = ParseObjectSpec( + filePath, + itemsElement, + $"{context.DisplayPath}[]", + $"{context.PropertyName}ItemConfig"); + if (objectResult.Diagnostic is not null) + { + return ParsedPropertyResult.FromDiagnostic(objectResult.Diagnostic); + } + + return ParsedPropertyResult.FromProperty(CreateObjectArrayPropertySpec( + property, + context, + itemsElement, + objectResult.Object!)); + } + + /// + /// Creates the nested object array property model after the item object has been parsed. + /// + /// Array property JSON node. + /// Property parsing context. + /// Array item schema node. + /// Parsed item object model. + /// Completed object array property model. + private static SchemaPropertySpec CreateObjectArrayPropertySpec( + JsonProperty property, + PropertyParseContext context, + JsonElement itemsElement, + SchemaObjectSpec objectSpec) + { + return new SchemaPropertySpec( + property.Name, + context.DisplayPath, + context.PropertyName, + context.IsRequired, + context.Title, + context.Description, + false, + new SchemaTypeSpec( + SchemaNodeKind.Array, + "array", + $"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>", + $" = global::System.Array.Empty<{objectSpec.ClassName}>();", + TryBuildEnumDocumentation(property.Value, "array") ?? + TryBuildEnumDocumentation(itemsElement, "object"), + TryBuildConstraintDocumentation(property.Value, "array"), + null, + null, + new SchemaTypeSpec( + SchemaNodeKind.Object, + "object", + objectSpec.ClassName, + null, + TryBuildEnumDocumentation(itemsElement, "object"), + null, + null, + objectSpec, + null))); + } + + /// + /// Creates a diagnostic for unsupported or malformed array item declarations. + /// + /// Schema file path used for diagnostics. + /// Logical schema path. + /// Unsupported item declaration text. + /// Unsupported property type diagnostic. + private static Diagnostic CreateUnsupportedArrayItemDiagnostic( + string filePath, + string displayPath, + string itemDescription) + { + return Diagnostic.Create( + ConfigSchemaDiagnostics.UnsupportedPropertyType, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + itemDescription); } /// @@ -2486,6 +2765,22 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); builder.AppendLine($"namespace {GeneratedNamespace};"); builder.AppendLine(); + AppendGeneratedConfigCatalogType(builder, schemas); + builder.AppendLine(); + AppendGeneratedConfigRegistrationOptionsType(builder, schemas); + builder.AppendLine(); + AppendGeneratedConfigRegistrationExtensionsType(builder, schemas); + + return builder.ToString().TrimEnd(); + } + + /// + /// Emits the generated catalog type that exposes schema metadata and filtering helpers. + /// + /// Output buffer. + /// Successfully parsed schemas for the current compilation. + private static void AppendGeneratedConfigCatalogType(StringBuilder builder, IReadOnlyList schemas) + { builder.AppendLine("/// "); builder.AppendLine( "/// Provides a project-level catalog for every config table generated from the current consumer project's schemas."); @@ -2753,7 +3048,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" return false;"); builder.AppendLine(" }"); builder.AppendLine("}"); - builder.AppendLine(); + } + + /// + /// Emits the options type consumed by aggregate generated table registration. + /// + /// Output buffer. + /// Successfully parsed schemas for the current compilation. + private static void AppendGeneratedConfigRegistrationOptionsType( + StringBuilder builder, + IReadOnlyList schemas) + { builder.AppendLine("/// "); builder.AppendLine( "/// Captures optional per-table registration overrides for the generated aggregate registration entry point."); @@ -2780,7 +3085,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" /// "); builder.AppendLine( " public global::System.Predicate? TableFilter { get; init; }"); + AppendGeneratedConfigComparerOptions(builder, schemas); + builder.AppendLine("}"); + } + /// + /// Emits one optional comparer property per generated table for aggregate registration. + /// + /// Output buffer. + /// Successfully parsed schemas for the current compilation. + private static void AppendGeneratedConfigComparerOptions( + StringBuilder builder, + IReadOnlyList schemas) + { if (schemas.Count > 0) { builder.AppendLine(); @@ -2801,15 +3118,35 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); } } + } - builder.AppendLine("}"); - builder.AppendLine(); + /// + /// Emits extension methods that register every generated config table for the current compilation. + /// + /// Output buffer. + /// Successfully parsed schemas for the current compilation. + private static void AppendGeneratedConfigRegistrationExtensionsType( + StringBuilder builder, + IReadOnlyList schemas) + { builder.AppendLine("/// "); builder.AppendLine( "/// Provides a single extension method that registers every generated config table discovered in the current consumer project."); builder.AppendLine("/// "); builder.AppendLine("public static class GeneratedConfigRegistrationExtensions"); builder.AppendLine("{"); + AppendRegisterAllGeneratedConfigTablesMethod(builder); + builder.AppendLine(); + AppendRegisterAllGeneratedConfigTablesWithOptionsMethod(builder, schemas); + builder.AppendLine("}"); + } + + /// + /// Emits the aggregate registration overload that uses default options. + /// + /// Output buffer. + private static void AppendRegisterAllGeneratedConfigTablesMethod(StringBuilder builder) + { builder.AppendLine(" /// "); builder.AppendLine( " /// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow."); @@ -2830,7 +3167,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); builder.AppendLine(" return RegisterAllGeneratedConfigTables(loader, options: null);"); builder.AppendLine(" }"); - builder.AppendLine(); + } + + /// + /// Emits the aggregate registration overload that honors generated filters and comparer overrides. + /// + /// Output buffer. + /// Successfully parsed schemas for the current compilation. + private static void AppendRegisterAllGeneratedConfigTablesWithOptionsMethod( + StringBuilder builder, + IReadOnlyList schemas) + { builder.AppendLine(" /// "); builder.AppendLine( " /// Registers all generated config tables while preserving optional per-table overrides such as custom key comparers."); @@ -2869,8 +3216,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" return loader;"); builder.AppendLine(" }"); - builder.AppendLine("}"); - return builder.ToString().TrimEnd(); } /// @@ -2881,6 +3226,20 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator private static void AppendYamlSerializationHelpers( StringBuilder builder, SchemaFileSpec schema) + { + AppendYamlSerializeMethod(builder, schema); + builder.AppendLine(); + AppendYamlPathMethods(builder); + builder.AppendLine(); + AppendYamlValidationMethods(builder); + } + + /// + /// Emits the generated YAML serialization method. + /// + /// Output buffer. + /// Generator-level schema model. + private static void AppendYamlSerializeMethod(StringBuilder builder, SchemaFileSpec schema) { builder.AppendLine(" /// "); builder.AppendLine( @@ -2895,7 +3254,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" {"); builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);"); builder.AppendLine(" }"); - builder.AppendLine(); + } + + /// + /// Emits generated helpers that resolve config and schema paths at runtime. + /// + /// Output buffer. + private static void AppendYamlPathMethods(StringBuilder builder) + { builder.AppendLine(" /// "); builder.AppendLine( " /// Resolves the absolute config directory path by combining the caller-supplied config root with the generated relative directory."); @@ -2925,7 +3291,25 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( " return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);"); builder.AppendLine(" }"); + } + + /// + /// Emits generated synchronous and asynchronous YAML validation methods. + /// + /// Output buffer. + private static void AppendYamlValidationMethods(StringBuilder builder) + { + AppendValidateYamlMethod(builder); builder.AppendLine(); + AppendValidateYamlAsyncMethod(builder); + } + + /// + /// Emits the generated synchronous YAML validation method. + /// + /// Output buffer. + private static void AppendValidateYamlMethod(StringBuilder builder) + { builder.AppendLine(" /// "); builder.AppendLine( " /// Validates YAML text against the generated schema file located under the supplied config root directory."); @@ -2950,7 +3334,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" yamlPath,"); builder.AppendLine(" yamlText);"); builder.AppendLine(" }"); - builder.AppendLine(); + } + + /// + /// Emits the generated asynchronous YAML validation method. + /// + /// Output buffer. + private static void AppendValidateYamlAsyncMethod(StringBuilder builder) + { builder.AppendLine(" /// "); builder.AppendLine( " /// Asynchronously validates YAML text against the generated schema file located under the supplied config root directory."); @@ -3091,6 +3482,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator private static void AppendSharedLookupIndexBuilderMethod( StringBuilder builder, SchemaFileSpec schema) + { + AppendSharedLookupIndexBuilderDocumentation(builder, schema); + AppendSharedLookupIndexBuilderBody(builder, schema); + } + + /// + /// Emits XML documentation and signature for the shared generated lookup index builder. + /// + /// Output buffer. + /// Generator-level schema model. + private static void AppendSharedLookupIndexBuilderDocumentation( + StringBuilder builder, + SchemaFileSpec schema) { builder.AppendLine(" /// "); builder.AppendLine( @@ -3113,6 +3517,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( " var buckets = new global::System.Collections.Generic.Dictionary>();"); + } + + /// + /// Emits the implementation body for the shared generated lookup index builder. + /// + /// Output buffer. + /// Generator-level schema model. + private static void AppendSharedLookupIndexBuilderBody( + StringBuilder builder, + SchemaFileSpec schema) + { builder.AppendLine(); builder.AppendLine( " // Capture the current table snapshot once so indexed lookups stay deterministic for this wrapper instance."); @@ -3184,6 +3599,21 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( $" public global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}> FindBy{property.PropertyName}({property.TypeSpec.ClrType} value)"); builder.AppendLine(" {"); + AppendFindByPropertyBody(builder, schema, property); + builder.AppendLine(" }"); + } + + /// + /// Emits the body of a generated FindBy* lookup helper. + /// + /// Output buffer. + /// Generator-level schema model. + /// Property model used by the lookup helper. + private static void AppendFindByPropertyBody( + StringBuilder builder, + SchemaFileSpec schema, + SchemaPropertySpec property) + { if (property.IsIndexedLookup) { if (RequiresIndexedLookupNullGuard(property.TypeSpec)) @@ -3222,8 +3652,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( $" return matches.Count == 0 ? global::System.Array.Empty<{schema.ClassName}>() : matches.AsReadOnly();"); } - - builder.AppendLine(" }"); } /// @@ -3262,6 +3690,21 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine( $" public bool TryFindFirstBy{property.PropertyName}({property.TypeSpec.ClrType} value, out {schema.ClassName}? result)"); builder.AppendLine(" {"); + AppendTryFindFirstByPropertyBody(builder, schema, property); + builder.AppendLine(" }"); + } + + /// + /// Emits the body of a generated TryFindFirstBy* lookup helper. + /// + /// Output buffer. + /// Generator-level schema model. + /// Property model used by the lookup helper. + private static void AppendTryFindFirstByPropertyBody( + StringBuilder builder, + SchemaFileSpec schema, + SchemaPropertySpec property) + { if (property.IsIndexedLookup) { if (RequiresIndexedLookupNullGuard(property.TypeSpec)) @@ -3301,8 +3744,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" result = null;"); builder.AppendLine(" return false;"); } - - builder.AppendLine(" }"); } /// @@ -3365,6 +3806,31 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator int indentationLevel) { var indent = new string(' ', indentationLevel * 4); + AppendObjectTypeHeader(builder, objectSpec, fileName, title, description, isRoot, indent); + AppendObjectTypeProperties(builder, objectSpec, indentationLevel); + AppendNestedObjectTypes(builder, objectSpec, fileName, indentationLevel); + builder.AppendLine($"{indent}}}"); + } + + /// + /// 生成单个配置对象类型的 XML 文档和类型声明。 + /// + /// 输出缓冲区。 + /// 要生成的对象类型。 + /// Schema 文件名。 + /// 对象标题元数据。 + /// 对象说明元数据。 + /// 是否为根配置类型。 + /// 当前缩进。 + private static void AppendObjectTypeHeader( + StringBuilder builder, + SchemaObjectSpec objectSpec, + string fileName, + string? title, + string? description, + bool isRoot, + string indent) + { builder.AppendLine($"{indent}/// "); if (isRoot) { @@ -3392,7 +3858,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine($"{indent}public sealed partial class {objectSpec.ClassName}"); builder.AppendLine($"{indent}{{"); + } + /// + /// 生成配置对象直接拥有的 CLR 属性。 + /// + /// 输出缓冲区。 + /// 要生成的对象类型。 + /// 当前缩进层级。 + private static void AppendObjectTypeProperties( + StringBuilder builder, + SchemaObjectSpec objectSpec, + int indentationLevel) + { for (var index = 0; index < objectSpec.Properties.Count; index++) { var property = objectSpec.Properties[index]; @@ -3409,7 +3887,21 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); builder.AppendLine(); } + } + /// + /// 在直接属性之后递归生成嵌套对象类型,保持输出顺序稳定。 + /// + /// 输出缓冲区。 + /// 父对象类型。 + /// Schema 文件名。 + /// 父对象缩进层级。 + private static void AppendNestedObjectTypes( + StringBuilder builder, + SchemaObjectSpec objectSpec, + string fileName, + int indentationLevel) + { var nestedTypes = CollectNestedTypes(objectSpec.Properties).ToArray(); for (var index = 0; index < nestedTypes.Length; index++) { @@ -3428,8 +3920,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(); } } - - builder.AppendLine($"{indent}}}"); } /// @@ -3957,13 +4447,38 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator private static string? TryBuildConstraintDocumentation(JsonElement element, string schemaType) { var parts = new List(); + AddConstDocumentationPart(element, schemaType, parts); + AddNumericConstraintDocumentationParts(element, schemaType, parts); + AddStringConstraintDocumentationParts(element, schemaType, parts); + AddArrayConstraintDocumentationParts(element, schemaType, parts); + AddObjectConstraintDocumentationParts(element, schemaType, parts); + return parts.Count > 0 ? string.Join(", ", parts) : null; + } + + /// + /// Adds const documentation when the schema value matches the current type. + /// + /// Schema node. + /// Current schema type. + /// Mutable documentation parts. + private static void AddConstDocumentationPart(JsonElement element, string schemaType, List parts) + { var constDocumentation = TryBuildConstDocumentation(element, schemaType); if (constDocumentation is not null) { parts.Add($"const = {constDocumentation}"); } + } + /// + /// Adds numeric range and step constraints to generated XML documentation. + /// + /// Schema node. + /// Current schema type. + /// Mutable documentation parts. + private static void AddNumericConstraintDocumentationParts(JsonElement element, string schemaType, List parts) + { if (IsNumericSchemaType(schemaType) && TryGetFiniteNumber(element, "minimum", out var minimum)) { @@ -3994,7 +4509,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { parts.Add($"multipleOf = {multipleOf.ToString(CultureInfo.InvariantCulture)}"); } + } + /// + /// Adds string length, pattern, and stable format constraints to generated XML documentation. + /// + /// Schema node. + /// Current schema type. + /// Mutable documentation parts. + private static void AddStringConstraintDocumentationParts(JsonElement element, string schemaType, List parts) + { if (IsSchemaType(schemaType, "string") && TryGetNonNegativeInt32(element, "minLength", out var minLength)) { @@ -4024,7 +4548,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"format = '{formatName}'"); } } + } + /// + /// Adds array count, uniqueness, contains, and negation constraints to generated XML documentation. + /// + /// Schema node. + /// Current schema type. + /// Mutable documentation parts. + private static void AddArrayConstraintDocumentationParts(JsonElement element, string schemaType, List parts) + { if (IsSchemaType(schemaType, "array") && TryGetNonNegativeInt32(element, "minItems", out var minItems)) { @@ -4070,7 +4603,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { parts.Add($"maxContains = {maxContains.ToString(CultureInfo.InvariantCulture)}"); } + } + /// + /// Adds object cardinality and composition constraints to generated XML documentation. + /// + /// Schema node. + /// Current schema type. + /// Mutable documentation parts. + private static void AddObjectConstraintDocumentationParts(JsonElement element, string schemaType, List parts) + { if (IsSchemaType(schemaType, "object") && TryGetNonNegativeInt32(element, "minProperties", out var minProperties)) { @@ -4109,8 +4651,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"if/then/else = {conditionalDocumentation}"); } } - - return parts.Count > 0 ? string.Join(", ", parts) : null; } /// @@ -4663,6 +5203,25 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator SchemaObjectSpec? NestedObject, SchemaTypeSpec? ItemTypeSpec); + /// + /// Shared state extracted before dispatching one property to a schema-type-specific parser. + /// + /// Logical schema path. + /// Generated CLR property name. + /// Whether the property is required. + /// Optional schema title. + /// Optional schema description. + /// Optional referenced table metadata. + /// Whether the property declares a generated exact-match index. + private sealed record PropertyParseContext( + string DisplayPath, + string PropertyName, + bool IsRequired, + string? Title, + string? Description, + string? RefTableName, + bool IsIndexedLookup); + /// /// 生成代码前的跨表引用字段种子信息。 /// diff --git a/ai-plan/public/analyzer-warning-reduction/todos/analyzer-warning-reduction-tracking.md b/ai-plan/public/analyzer-warning-reduction/todos/analyzer-warning-reduction-tracking.md index d27049dc..88a0b945 100644 --- a/ai-plan/public/analyzer-warning-reduction/todos/analyzer-warning-reduction-tracking.md +++ b/ai-plan/public/analyzer-warning-reduction/todos/analyzer-warning-reduction-tracking.md @@ -7,8 +7,8 @@ ## 当前恢复点 -- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-019` -- 当前阶段:`Phase 19` +- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-020` +- 当前阶段:`Phase 20` - 当前焦点: - 已完成 `GFramework.Core` 当前 `MA0016` / `MA0002` / `MA0015` / `MA0077` 低风险收口批次 - 已复核 `net10.0` 下的 `MA0158` 基线:`GFramework.Core` / `GFramework.Cqrs` 当前共有 `16` 个 object lock @@ -17,6 +17,8 @@ - 已完成 `GFramework.Cqrs.SourceGenerators/Cqrs/CqrsHandlerRegistryGenerator.cs` 的 `MA0051` 结构拆分,生成输出保持不变 - 已完成 `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` 的 `MA0006` 低风险收口,schema 关键字比较显式使用 `StringComparison.Ordinal` + - 已完成 `SchemaConfigGenerator.cs` 的第一批 `MA0051` 结构拆分:schema 入口解析、属性解析、schema 遍历、数组属性解析、 + 约束文档生成与若干生成代码发射 helper 已拆出语义阶段 - `LoggingConfiguration`、`FilterConfiguration` 与 `CollectionExtensions` 已改用集合抽象接口,并保留内部具体集合默认值 - `CoroutineScheduler` 的 tag/group 字典已显式使用 `StringComparer.Ordinal`,保持既有区分大小写语义 - `EasyEvents.AddEvent()` 的重复注册路径已改为状态冲突异常,避免把泛型类型参数伪装成方法参数名 @@ -24,7 +26,7 @@ - 当前 `GFramework.Core` `net8.0` warnings-only 基线已降到 `0` 条 - 当前 `GFramework.Core.SourceGenerators` warnings-only 基线已降到 `0` 条 - 当前 `GFramework.Cqrs.SourceGenerators` warnings-only 基线已降到 `0` 条 - - 当前 `GFramework.Game.SourceGenerators` warnings-only 基线已从 `46` 条降到 `19` 条,剩余均为 + - 当前 `GFramework.Game.SourceGenerators` warnings-only 基线已从 `46` 条降到 `9` 条,剩余均为 `SchemaConfigGenerator.cs` 的 `MA0051` - `GFramework.Godot` 的 `Timing.cs` 已同步适配新事件签名,但当前 worktree 的 Godot restore 资产仍受 Windows fallback package folder 干扰,独立 build 需在修复资产后补跑 - 后续继续按 warning 类型和数量批处理,而不是回退到按单文件切片推进 @@ -49,7 +51,7 @@ - 已完成当前 `GFramework.Core` `net8.0` 剩余低风险 analyzer warning 批次;warnings-only 基线已降到 `0` 条 - 已完成 `GFramework.Core.SourceGenerators` 中 `ContextAwareGenerator` 的剩余 `MA0051` 收口;warnings-only 基线已降到 `0` 条 - 已完成 `GFramework.Cqrs.SourceGenerators` 中 `CqrsHandlerRegistryGenerator` 的剩余 `MA0051` 收口;warnings-only 基线已降到 `0` 条 -- 已完成 `GFramework.Game.SourceGenerators` 中 `SchemaConfigGenerator` 的 `MA0006` 收口;warnings-only 基线剩余 `19` 条 +- 已完成 `GFramework.Game.SourceGenerators` 中 `SchemaConfigGenerator` 的第一批 `MA0051` 收口;warnings-only 基线剩余 `9` 条 `MA0051` ## 当前活跃事实 @@ -89,6 +91,8 @@ 通过拆分 handler 分析、运行时类型引用构造、注册器源码发射与精确反射注册输出阶段,清空该项目当前 `MA0051` - `RP-019` 转入 `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs`,先完成低风险 `MA0006` 批次; 通过 schema 类型比较 helper 与显式 `StringComparison.Ordinal` 清空当前项目的 `MA0006` +- `RP-020` 继续拆分 `SchemaConfigGenerator.cs` 的 `MA0051` 热点,将当前项目 warnings-only 基线从 `19` 条降到 `9` 条, + 并用 focused schema generator tests 验证 50 个用例通过 - 当前工作树分支 `fix/analyzer-warning-reduction-batch` 已在 `ai-plan/public/README.md` 建立 topic 映射 ## 当前风险 @@ -216,13 +220,19 @@ - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~SchemaConfigGenerator -m:1 -p:RestoreFallbackFolders= -nologo` - 结果:`50 Passed`,`0 Failed` - 说明:测试项目构建仍显示既有 source generator test analyzer warning;不属于本轮写集 +- `RP-020` 的验证结果: + - `dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders= -nologo -clp:"Summary;WarningsOnly"` + - 结果:`9 Warning(s)`,`0 Error(s)`;当前项目剩余 warning 均为 `SchemaConfigGenerator.cs` 的 `MA0051` + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~SchemaConfigGenerator -m:1 -p:RestoreFallbackFolders= -nologo` + - 结果:`50 Passed`,`0 Failed` + - 说明:测试项目构建仍显示既有 source generator test analyzer warning;不属于本轮写集 - active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,不再重复保存已完成阶段的长篇历史 ## 下一步 1. 若要继续该主题,先读 active tracking,再按需展开历史归档中的 warning 热点与验证记录 -2. 下一轮优先继续拆分 `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` 的 `MA0051`;建议先从 - `TryBuildConstraintDocumentation` 或 `GenerateConfigCatalogSource` 这类高收益方法切入 +2. 下一轮优先继续拆分 `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` 的剩余 `MA0051`;建议先从 + `GenerateBindingsClass`、`AppendGeneratedConfigCatalogType` 或对象/条件 schema target 验证方法切入 3. 若改回推进 `MA0158`,先设计 `net8.0` / `net9.0` / `net10.0` 多 target 条件编译方案,不直接批量替换共享源码中的 `object` lock 4. 若后续继续改动 `GFramework.Godot`,先修复该项目的 Linux 侧 restore 资产,再补跑独立 build diff --git a/ai-plan/public/analyzer-warning-reduction/traces/analyzer-warning-reduction-trace.md b/ai-plan/public/analyzer-warning-reduction/traces/analyzer-warning-reduction-trace.md index 58b721a6..f43089c0 100644 --- a/ai-plan/public/analyzer-warning-reduction/traces/analyzer-warning-reduction-trace.md +++ b/ai-plan/public/analyzer-warning-reduction/traces/analyzer-warning-reduction-trace.md @@ -1,5 +1,32 @@ # Analyzer Warning Reduction 追踪 +## 2026-04-22 — RP-020 + +### 阶段:`SchemaConfigGenerator` 第一批 `MA0051` 结构拆分(RP-020) + +- 启动复核: + - 当前 worktree 仍映射到 `analyzer-warning-reduction` active topic + - `GFramework.Game.SourceGenerators` warnings-only build 复现 `19` 条 warning,全部为 + `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs` 的 `MA0051` +- 决策: + - 本轮继续低风险结构拆分,不改变 schema 支持范围、诊断 ID、生成类型形状或输出顺序 + - 未使用 subagent;critical path 是本地复现 warning、拆分语义阶段并用 focused schema generator tests 验证行为 +- 实施调整: + - 将 schema 入口解析拆为文本读取、root 验证、id key 验证和 `SchemaFileSpec` 构造阶段 + - 将属性解析拆为共享上下文提取、类型分派、标量/对象/数组属性构造 helper + - 将统一 schema 遍历拆为对象属性、dependentSchemas、allOf、条件分支、not、array items / contains 等遍历阶段 + - 将约束文档生成拆为 const、numeric、string、array、object 约束片段 + - 将 catalog/registration/YAML/lookup/object type 等生成代码发射路径中的小型高收益 helper 拆出 +- 验证结果: + - `dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:RestoreFallbackFolders= -nologo -clp:"Summary;WarningsOnly"` + - 结果:`9 Warning(s)`,`0 Error(s)`;当前项目剩余 warning 均为 `SchemaConfigGenerator.cs` 的 `MA0051` + - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~SchemaConfigGenerator -m:1 -p:RestoreFallbackFolders= -nologo` + - 结果:`50 Passed`,`0 Failed` + - 说明:测试项目构建仍显示既有 source generator test analyzer warning;不属于本轮写集 +- 下一步建议: + - 继续该主题时,优先拆分 `GenerateBindingsClass`、`AppendGeneratedConfigCatalogType` 或对象/条件 schema target 验证方法 + - 若转回 `MA0158`,仍需先设计多 target 条件编译方案,再考虑替换共享源码中的 `object` lock + ## 2026-04-22 — RP-019 ### 阶段:`SchemaConfigGenerator` 当前 `MA0006` 收口(RP-019)