diff --git a/AGENTS.md b/AGENTS.md index e369f99..f3fc821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,12 +151,15 @@ dotnet test DesignPatterns.slnx -c Release | DP060 | DI captive dependency(Analyzer;`RegisterDi` registryLifetime > implementationLifetime 警告) | | DP061 | DI lifetime wasteful(Analyzer;`RegisterDi` implementationLifetime > registryLifetime 提示) | | DP062 | Singleton captive dependency(Analyzer;Singleton 实现构造函数依赖 Scoped/Transient 服务) | +| DP063 | Composite tree max depth exceeded(生成器;`[CompositeSchema(MaxDepth)]` 约束被超过 Warning) | +| DP064 | Composite child type not allowed(生成器;子节点实现类型不在父节点 `AllowedChildTypes` 集合中 Error) | +| DP065 | Composite node count exceeded(生成器;`[CompositeSchema(MaxNodes)]` 约束被超过 Warning) | 常量:[`DesignPatterns.Diagnostics/DiagnosticIds.cs`](DesignPatterns.Diagnostics/DiagnosticIds.cs)。规则表:[`DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md`](DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md)。 诊断 ID 规范(**本表为唯一登记源**,其他文档不得另立分类): -- 下一个可用 ID:**DP063**;ID 一经发布不复用、不改语义。 +- 下一个可用 ID:**DP066**;ID 一经发布不复用、不改语义。 - 新增 / 修改诊断必须同步 [`DiagnosticIds.cs`](DesignPatterns.Diagnostics/DiagnosticIds.cs)、[`DesignPatternsDiagnosticDescriptors.cs`](DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs)(经 Compile Link 编入 SourceGenerators / Analyzers)与 [`AnalyzerReleases.Unshipped.md`](DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md)。 - 归属:DP006 / DP023 / DP024 / DP025 / DP033 / DP036 / DP044 / DP060 / DP061 / DP062 属 **Analyzer**;其余属**生成器**。 - 文案:`messageFormat` 须含可操作建议;`description` 供 IDE 悬停;`helpLinkUri` 指向 [`DesignPatterns.Docs` diagnostics 页](https://skymly.github.io/DesignPatterns.Docs/diagnostics)(`#dp###` 片段,见 [`DiagnosticHelpLinks.cs`](DesignPatterns.Diagnostics/DiagnosticHelpLinks.cs))。 diff --git a/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs b/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs index e3587b1..56fd507 100644 --- a/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs +++ b/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs @@ -335,6 +335,32 @@ public static class DesignPatternsDiagnosticDescriptors DiagnosticSeverity.Warning, AnalyzerCategory); + // Composite tree schema validation (DP063–DP065) + + public static DiagnosticDescriptor CompositeTreeMaxDepthExceeded { get; } = Create( + DiagnosticIds.CompositeTreeMaxDepthExceeded, + "Composite tree max depth exceeded", + "Composite tree for contract '{0}' has depth {1}, exceeding the maximum depth of {2} declared by [CompositeSchema]. Consider flattening the tree structure or increasing MaxDepth.", + "Excessively deep composite trees can cause stack overflows in recursive traversal. Review the tree structure or adjust the MaxDepth constraint.", + DiagnosticSeverity.Warning, + GeneratorCategory); + + public static DiagnosticDescriptor CompositeChildTypeNotAllowed { get; } = Create( + DiagnosticIds.CompositeChildTypeNotAllowed, + "Composite child type not allowed by parent", + "Composite part '{0}' (type '{1}') is not in the AllowedChildTypes of its parent '{2}' (type '{3}'). Add '{1}' to the parent's AllowedChildTypes or change the ParentKey to a compatible parent.", + "Parent-child type compatibility is enforced at compile time when AllowedChildTypes is specified. Ensure child implementation types are declared in the parent's AllowedChildTypes array.", + DiagnosticSeverity.Error, + GeneratorCategory); + + public static DiagnosticDescriptor CompositeNodeCountExceeded { get; } = Create( + DiagnosticIds.CompositeNodeCountExceeded, + "Composite node count exceeds limit", + "Composite contract '{0}' has {1} parts, exceeding the maximum of {2} declared by [CompositeSchema]. Consider splitting into multiple contracts or increasing MaxNodes.", + "Excessive node counts may indicate architectural issues or cause performance problems in tree assembly and traversal.", + DiagnosticSeverity.Warning, + GeneratorCategory); + public static DiagnosticDescriptor RegistryKeyNotRegistered { get; } = Create( DiagnosticIds.RegistryKeyNotRegistered, "Registry key is not registered", diff --git a/DesignPatterns.Diagnostics/DiagnosticIds.cs b/DesignPatterns.Diagnostics/DiagnosticIds.cs index 96eb269..bbec1d0 100644 --- a/DesignPatterns.Diagnostics/DiagnosticIds.cs +++ b/DesignPatterns.Diagnostics/DiagnosticIds.cs @@ -67,4 +67,7 @@ public static class DiagnosticIds public const string DiLifetimeCaptiveDependency = "DP060"; public const string DiLifetimeWasteful = "DP061"; public const string CaptiveDependency = "DP062"; + public const string CompositeTreeMaxDepthExceeded = "DP063"; + public const string CompositeChildTypeNotAllowed = "DP064"; + public const string CompositeNodeCountExceeded = "DP065"; } diff --git a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md index 3ecf931..5b1724b 100644 --- a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -64,3 +64,6 @@ DP059 | DesignPatterns.Generators | Info | StateParent parent has no chil DP060 | DesignPatterns.Analyzers | Warning | DI captive dependency: registry lifetime exceeds implementation lifetime DP061 | DesignPatterns.Analyzers | Info | DI lifetime mismatch: implementation lifetime exceeds registry lifetime DP062 | DesignPatterns.Analyzers | Warning | Singleton captive dependency on Scoped/Transient service +DP063 | DesignPatterns.Generators | Warning | Composite tree max depth exceeded +DP064 | DesignPatterns.Generators | Error | Composite child type not allowed by parent AllowedChildTypes +DP065 | DesignPatterns.Generators | Warning | Composite node count exceeds MaxNodes limit diff --git a/DesignPatterns.SourceGenerators/Generators/CompositePartGenerator.cs b/DesignPatterns.SourceGenerators/Generators/CompositePartGenerator.cs index 1036688..477e9f2 100644 --- a/DesignPatterns.SourceGenerators/Generators/CompositePartGenerator.cs +++ b/DesignPatterns.SourceGenerators/Generators/CompositePartGenerator.cs @@ -41,6 +41,15 @@ public sealed class CompositePartGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor MissingBuildableDescriptor = DesignPatternsDiagnosticDescriptors.CompositePartMissingBuildable; + private static readonly DiagnosticDescriptor TreeMaxDepthExceededDescriptor = + DesignPatternsDiagnosticDescriptors.CompositeTreeMaxDepthExceeded; + + private static readonly DiagnosticDescriptor ChildTypeNotAllowedDescriptor = + DesignPatternsDiagnosticDescriptors.CompositeChildTypeNotAllowed; + + private static readonly DiagnosticDescriptor NodeCountExceededDescriptor = + DesignPatternsDiagnosticDescriptors.CompositeNodeCountExceeded; + /// public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -109,6 +118,7 @@ private static Result Transform(GeneratorAttributeSyntaxC string? parentKey = null; var order = 0; + EquatableArray allowedChildTypes = default; foreach (var named in attribute.NamedArguments) { if (named.Key == "ParentKey" && named.Value.Value is string parent) @@ -119,6 +129,10 @@ private static Result Transform(GeneratorAttributeSyntaxC { order = orderValue; } + else if (named.Key == "AllowedChildTypes" && !named.Value.IsNull) + { + allowedChildTypes = ExtractAllowedChildTypes(named.Value); + } } var contractInfo = new ContractInfo( @@ -129,6 +143,8 @@ private static Result Transform(GeneratorAttributeSyntaxC : contract.ContainingNamespace.ToDisplayString(), contract.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + var (schemaMaxDepth, schemaMaxNodes, schemaLocation) = ExtractSchemaInfo(contract); + return Result.Success(new CompositeRegistration( key!, parentKey, @@ -142,7 +158,11 @@ private static Result Transform(GeneratorAttributeSyntaxC ImplementsContract(implementation, contract), HasPublicParameterlessConstructor(implementation), ImplementsBuildable(implementation, contract), - new LocationInfo(context.TargetNode.GetLocation()))); + new LocationInfo(context.TargetNode.GetLocation()), + allowedChildTypes, + schemaMaxDepth, + schemaMaxNodes, + schemaLocation)); } return Result.Empty; @@ -171,6 +191,7 @@ private static void Execute( ReportContractMismatches(context, contractRegistrations); ReportMissingConstructors(context, contractRegistrations); ReportMissingBuildable(context, contractRegistrations, contract); + ReportSchemaViolations(context, contractRegistrations, contract); var valid = contractRegistrations .Where(static r => r.ImplementsContract) @@ -279,6 +300,219 @@ private static void ReportMissingBuildable( } } + private static void ReportSchemaViolations( + SourceProductionContext context, + List registrations, + ContractInfo contract) + { + // All registrations for the same contract share the same schema info + // (extracted from [CompositeSchema] on the contract type). + var schemaMaxDepth = registrations.FirstOrDefault()?.SchemaMaxDepth; + var schemaMaxNodes = registrations.FirstOrDefault()?.SchemaMaxNodes; + var schemaLocation = registrations.FirstOrDefault()?.SchemaLocation; + + // DP065: Node count exceeded + if (schemaMaxNodes is { } maxNodes and > 0 && registrations.Count > maxNodes) + { + context.ReportDiagnostic(Diagnostic.Create( + NodeCountExceededDescriptor, + schemaLocation?.ToLocation() ?? Location.None, + contract.FullyQualifiedName, + registrations.Count, + maxNodes)); + } + + // DP063: Max depth exceeded + if (schemaMaxDepth is { } maxDepth and > 0) + { + var actualDepth = ComputeMaxDepth(registrations); + if (actualDepth > maxDepth) + { + // Report on the deepest leaf node + var deepestLeaf = FindDeepestLeaf(registrations, maxDepth); + context.ReportDiagnostic(Diagnostic.Create( + TreeMaxDepthExceededDescriptor, + deepestLeaf?.Location.ToLocation() ?? Location.None, + contract.FullyQualifiedName, + actualDepth, + maxDepth)); + } + } + + // DP064: Child type not allowed by parent + ReportDisallowedChildTypes(context, registrations, contract); + } + + private static void ReportDisallowedChildTypes( + SourceProductionContext context, + List registrations, + ContractInfo contract) + { + // Build a key-to-registration lookup that tolerates duplicate keys + // (DP010 already reports duplicates; we just skip them here). + var entryByKey = new Dictionary(StringComparer.Ordinal); + foreach (var reg in registrations) + { + if (!entryByKey.ContainsKey(reg.Key)) + { + entryByKey[reg.Key] = reg; + } + } + + foreach (var registration in registrations) + { + if (registration.ParentKey is null) + { + continue; + } + + if (!entryByKey.TryGetValue(registration.ParentKey, out var parent)) + { + continue; // DP011 already handles unknown parent keys + } + + if (parent.AllowedChildTypes.Count == 0) + { + continue; // No restriction + } + + if (!parent.AllowedChildTypes.Contains(registration.ImplementationFullyQualifiedDisplayString)) + { + context.ReportDiagnostic(Diagnostic.Create( + ChildTypeNotAllowedDescriptor, + registration.Location.ToLocation(), + registration.Key, + registration.ImplementationName, + parent.Key, + parent.ImplementationName)); + } + } + } + + private static int ComputeMaxDepth(List registrations) + { + // Build children-by-parent map + var childrenByParent = new Dictionary>(StringComparer.Ordinal); + var keysWithNoParent = new List(); + + foreach (var reg in registrations) + { + if (reg.ParentKey is null) + { + keysWithNoParent.Add(reg.Key); + } + else + { + if (!childrenByParent.TryGetValue(reg.ParentKey, out var children)) + { + children = new List(); + childrenByParent[reg.ParentKey] = children; + } + children.Add(reg.Key); + } + } + + // Memoized depth computation + var depthCache = new Dictionary(StringComparer.Ordinal); + + int Depth(string key) + { + if (depthCache.TryGetValue(key, out var cached)) + { + return cached; + } + + if (!childrenByParent.TryGetValue(key, out var children) || children.Count == 0) + { + depthCache[key] = 1; + return 1; + } + + var maxChildDepth = 0; + foreach (var child in children) + { + maxChildDepth = Math.Max(maxChildDepth, Depth(child)); + } + + var result = 1 + maxChildDepth; + depthCache[key] = result; + return result; + } + + var max = 0; + foreach (var root in keysWithNoParent) + { + max = Math.Max(max, Depth(root)); + } + + // Also handle keys that are roots but also appear as children (shouldn't happen + // after cycle detection, but guard anyway) + foreach (var reg in registrations) + { + max = Math.Max(max, Depth(reg.Key)); + } + + return max; + } + + private static CompositeRegistration? FindDeepestLeaf(List registrations, int maxDepth) + { + var childrenByParent = new Dictionary>(StringComparer.Ordinal); + foreach (var reg in registrations) + { + if (reg.ParentKey is not null) + { + if (!childrenByParent.TryGetValue(reg.ParentKey, out var children)) + { + children = new List(); + childrenByParent[reg.ParentKey] = children; + } + children.Add(reg.Key); + } + } + + var depthCache = new Dictionary(StringComparer.Ordinal); + + int Depth(string key) + { + if (depthCache.TryGetValue(key, out var cached)) + { + return cached; + } + + if (!childrenByParent.TryGetValue(key, out var children) || children.Count == 0) + { + depthCache[key] = 1; + return 1; + } + + var maxChildDepth = 0; + foreach (var child in children) + { + maxChildDepth = Math.Max(maxChildDepth, Depth(child)); + } + + var result = 1 + maxChildDepth; + depthCache[key] = result; + return result; + } + + CompositeRegistration? deepest = null; + var deepestDepth = 0; + + foreach (var reg in registrations) + { + var depth = Depth(reg.Key); + if (depth > deepestDepth) + { + deepestDepth = depth; + deepest = reg; + } + } + + return deepest; + } + private static void EmitGeneratedSources( SourceProductionContext context, ContractInfo contract, @@ -417,6 +651,64 @@ private static bool HasPublicParameterlessConstructor(INamedTypeSymbol implement implementation.InstanceConstructors.Any(static c => c.Parameters.IsEmpty && c.DeclaredAccessibility == Accessibility.Public); + private static EquatableArray ExtractAllowedChildTypes(TypedConstant value) + { + if (value.IsNull || value.Values.IsDefaultOrEmpty) + { + return default; + } + + var types = new List(value.Values.Length); + foreach (var element in value.Values) + { + if (element.Value is INamedTypeSymbol typeSymbol) + { + types.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + } + + return new EquatableArray(types.ToArray()); + } + + private static (int? MaxDepth, int? MaxNodes, LocationInfo? Location) ExtractSchemaInfo(INamedTypeSymbol contract) + { + foreach (var attr in contract.GetAttributes()) + { + if (attr.AttributeClass is null || + attr.AttributeClass.Name != "CompositeSchemaAttribute" || + attr.AttributeClass.ContainingNamespace?.ToDisplayString() != "DesignPatterns.Structural") + { + continue; + } + + int? maxDepth = null; + int? maxNodes = null; + + foreach (var named in attr.NamedArguments) + { + if (named.Key == "MaxDepth" && named.Value.Value is int depth) + { + maxDepth = depth; + } + else if (named.Key == "MaxNodes" && named.Value.Value is int nodes) + { + maxNodes = nodes; + } + } + + // Also check constructor arguments (positional) + for (var i = 0; i < attr.ConstructorArguments.Length; i++) + { + // CompositeSchemaAttribute has parameterless constructor; all values via named args + } + + var location = attr.ApplicationSyntaxReference?.GetSyntax().GetLocation(); + return (maxDepth, maxNodes, location is not null ? new LocationInfo(location) : null); + } + + return (null, null, null); + } + private static bool ImplementsBuildable(INamedTypeSymbol implementation, INamedTypeSymbol contract) { foreach (var iface in implementation.AllInterfaces) @@ -446,5 +738,9 @@ private sealed record CompositeRegistration( bool ImplementsContract, bool HasPublicParameterlessConstructor, bool ImplementsBuildable, - LocationInfo Location); + LocationInfo Location, + EquatableArray AllowedChildTypes, + int? SchemaMaxDepth, + int? SchemaMaxNodes, + LocationInfo? SchemaLocation); } diff --git a/DesignPatterns/Structural/CompositePartAttribute.TContract.cs b/DesignPatterns/Structural/CompositePartAttribute.TContract.cs index 61346b9..d8fc592 100644 --- a/DesignPatterns/Structural/CompositePartAttribute.TContract.cs +++ b/DesignPatterns/Structural/CompositePartAttribute.TContract.cs @@ -39,6 +39,13 @@ public CompositePartAttribute(string key) /// Order among siblings with the same parent. Lower values come first. /// public int Order { get; set; } + + /// + /// Allowed child implementation types, or to allow any + /// type implementing the contract. When set, the source generator validates + /// that each child's implementation type is in this list. + /// + public Type[]? AllowedChildTypes { get; set; } } #endif diff --git a/DesignPatterns/Structural/CompositePartAttribute.cs b/DesignPatterns/Structural/CompositePartAttribute.cs index 51cb59c..8cf0873 100644 --- a/DesignPatterns/Structural/CompositePartAttribute.cs +++ b/DesignPatterns/Structural/CompositePartAttribute.cs @@ -44,4 +44,11 @@ public CompositePartAttribute(string key, Type @for) /// Order among siblings with the same parent. Lower values come first. /// public int Order { get; set; } + + /// + /// Allowed child implementation types, or to allow any + /// type implementing the contract. When set, the source generator validates + /// that each child's implementation type is in this list. + /// + public Type[]? AllowedChildTypes { get; set; } } diff --git a/DesignPatterns/Structural/CompositeSchemaAttribute.cs b/DesignPatterns/Structural/CompositeSchemaAttribute.cs new file mode 100644 index 0000000..af3af51 --- /dev/null +++ b/DesignPatterns/Structural/CompositeSchemaAttribute.cs @@ -0,0 +1,26 @@ +using System; + +namespace DesignPatterns.Structural; + +/// +/// Declares compile-time tree structure constraints for a composite contract. +/// Apply to the contract interface or base class to enable schema validation. +/// +/// +/// When applied, the source generator validates the composite tree structure at compile time: +/// limits tree depth, limits total node count. +/// All constraints are opt-in — unmarked contracts behave as before. +/// +[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class CompositeSchemaAttribute : Attribute +{ + /// + /// Maximum tree depth (root = depth 1). 0 = no limit. Default = 0. + /// + public int MaxDepth { get; set; } + + /// + /// Maximum total node count across all roots. 0 = no limit. Default = 0. + /// + public int MaxNodes { get; set; } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b16fde5..13eb5f7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,7 +20,7 @@ | Event Aggregator | `{Event}EventHandlerRegistry` | | State | `{StateEnum}TransitionTable`、partial `{Holder}` 便捷方法 | -新增生成器必须沿用此命名风格(详见 [Decorator.md](Decorator.md))。诊断 ID 续接现有区段,下一个可用 ID 为 **DP056**(DP053–DP055 为 Factory async + pooling 签名/池化校验,DP050–DP052 为 Handler guard 签名校验,DP047–DP049 为 Strategy guard 签名校验,DP044–DP046 为 EventAggregator 源生成器 + Analyzer 诊断,DP042–DP043 为 Decorator DI + async 签名校验,DP040–DP041 为 Composite DI + visitor 覆盖校验,DP037–DP039 为 State entry/exit action 诊断,DP032–DP035 为 State guard 诊断,DP036 为 State 字面量边校验;ID 一经发布不复用,详见 [AGENTS.md](../AGENTS.md))。 +新增生成器必须沿用此命名风格(详见 [Decorator.md](Decorator.md))。诊断 ID 续接现有区段,下一个可用 ID 为 **DP066**(DP063–DP065 为 Composite 树 schema 校验,DP060–DP062 为 DI 生命周期校验,DP056–DP059 为 State hierarchy,DP053–DP055 为 Factory async + pooling 签名/池化校验,DP050–DP052 为 Handler guard 签名校验,DP047–DP049 为 Strategy guard 签名校验,DP044–DP046 为 EventAggregator 源生成器 + Analyzer 诊断,DP042–DP043 为 Decorator DI + async 签名校验,DP040–DP041 为 Composite DI + visitor 覆盖校验,DP037–DP039 为 State entry/exit action 诊断,DP032–DP035 为 State guard 诊断,DP036 为 State 字面量边校验;ID 一经发布不复用,详见 [AGENTS.md](../AGENTS.md))。 诊断 ID 预分配(F2+ 增强项,登记后不提前占用,实现时按序领取): @@ -106,7 +106,7 @@ | ~~State 层级状态机~~ | ~~`[StateMachine(..., Hierarchical = true)]` 支持嵌套状态 + 通配转换,生成器展平为快表~~ — **已在 v3 实现**(v3.1 运行时 `IStateHierarchy`、v3.2 `[StateParent]` + 展平 + DP056–DP059、v3.3 LCA + action 链合成、v3.4 DI + 示例 + 文档) | ⭐⭐⭐ 与 Stateless 重叠但展示「编译期展平层级」技术 | | ~~Composite 并行遍历~~ | ~~`TraverseParallel` / `TraverseParallelAsync` / `TraverseForestParallel` / `TraverseForestParallelAsync` + `MaxDegreeOfParallelism` + `MaxParallelDepth`~~ — **Phase 1 已实现**(BFS 同层并行 / DFS 子节点并行递归 + 深度回退 / `AggregateException` / `ConfigureAwait(false)` / `#if` TFM 分裂) | ⭐⭐ 大树场景实用;AOT 友好并行调度 | | Composite 懒加载 | `[CompositePart(..., LazyChildren = true)]` + `AssembleAsync` + `ICompositeLazyNode` | ⭐⭐ 大树按需展开;Phase 3 独立 RFC | -| Composite 树 schema 校验 | 编译期校验 max depth / parent-child 类型兼容性 / 节点计数 | ⭐⭐ 结构错误编译期捕获 | +| ~~Composite 树 schema 校验~~ | ~~编译期校验 max depth / parent-child 类型兼容性 / 节点计数~~ — **已实现**(`[CompositeSchema(MaxDepth, MaxNodes)]` 契约级约束 + `[CompositePart(AllowedChildTypes)]` 节点级约束 + DP063–DP065) | ⭐⭐ 结构错误编译期捕获 | | Decorator 组合 / 嵌套 | `DecoratorStackBuilder.Compose(otherStack)`,Analyzer 校验栈间类型兼容 | ⭐⭐ 可复用装饰器组合 | | MSDI keyed services(.NET 8+) | 生成 keyed registration 代码(`#if NET8_0_OR_GREATER`),与 Autofac keyed 对称 | ⭐⭐ 补齐 MSDI keyed 缺口 | | DI 健康检查 + 生命周期校验 | 生成 `IHealthCheck` 校验注册表键可解析;Analyzer 警告无效 lifetime 组合(Singleton registry + Transient impl) | ⭐⭐ 生产场景刚需 | @@ -207,3 +207,4 @@ State 转换表 v1 已于 0.1.0-preview4 发布;v2(guard 委托、DI 集成 - F4+ DP062 过度注册修复:按模式分组特性标注类型(`RegistrationCategory`),`RegisterDi` containing type 名匹配正确类别,避免跨模式 lifetime 污染。 - F5 Composite 并行遍历 Phase 1:`TraverseParallel` / `TraverseParallelAsync` / `TraverseForestParallel` / `TraverseForestParallelAsync` + `MaxDegreeOfParallelism` + `MaxParallelDepth` + `AggregateException` + `ConfigureAwait(false)` + `#if` TFM 分裂。设计 RFC:[docs/rfc/CompositeParallelTraversal.md](rfc/CompositeParallelTraversal.md)。 - 发版 `0.2.2`:含 DP062 Singleton captive dependency 诊断(tag `v0.2.2`,稳定版)。 +- F5+ Composite 树 schema 校验:`[CompositeSchema(MaxDepth, MaxNodes)]` 契约级约束 + `[CompositePart(AllowedChildTypes)]` 节点级约束 + DP063(max depth exceeded Warning)/ DP064(child type not allowed Error)/ DP065(node count exceeded Warning)。设计 RFC:[docs/rfc/CompositeTreeSchemaValidation.md](rfc/CompositeTreeSchemaValidation.md)。 diff --git a/docs/rfc/CompositeTreeSchemaValidation.md b/docs/rfc/CompositeTreeSchemaValidation.md new file mode 100644 index 0000000..c1236cc --- /dev/null +++ b/docs/rfc/CompositeTreeSchemaValidation.md @@ -0,0 +1,287 @@ +# RFC: Composite 树 schema 校验 + +> **状态**:设计完成,待实现 +> **日期**:2026-07-15 +> **关联**:[Composite.md](../Composite.md)、[ROADMAP.md](../ROADMAP.md)、[CompositeParallelTraversal.md](CompositeParallelTraversal.md) + +## 概述 + +为 Composite 模式新增编译期树结构 schema 校验:**最大深度**、**父子类型兼容性**、**节点计数**。在源生成器阶段从 flat parent-key 映射计算树结构并报告诊断,无需运行时开销。 + +现有 Composite 诊断(DP010–DP015, DP040–DP041)覆盖键唯一性、父键存在性、循环检测、契约实现、构造函数、DI 注册等基础校验,但**不**校验树的整体结构形态。本 RFC 补齐这一层。 + +## 设计目标 + +| 目标 | 说明 | +|------|------| +| 编译期捕获结构错误 | 深度过大可能导致栈溢出;类型不匹配的父子关系是设计错误;节点过多暗示架构问题 | +| 零运行时开销 | 所有校验在源生成器阶段完成,不增加运行时成本 | +| 向后兼容 | 所有新约束均为 opt-in,不标注则不校验(现有代码行为不变) | +| 探索价值 | 展示「源生成器从 flat 声明重建树拓扑并在编译期校验」的编译期 + 运行时协同模式 | + +--- + +## 一、API 设计 + +### 1.1 `CompositeSchemaAttribute`(契约级约束) + +新增特性,标注在**契约接口/基类**上,声明该契约的全局树结构约束: + +```csharp +[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class CompositeSchemaAttribute : Attribute +{ + /// Maximum tree depth (root = depth 1). 0 = no limit. Default = 0. + public int MaxDepth { get; set; } + + /// Maximum total node count across all roots. 0 = no limit. Default = 0. + public int MaxNodes { get; set; } +} +``` + +**用法示例**: + +```csharp +[CompositeSchema(MaxDepth = 10, MaxNodes = 500)] +public interface IMenuNode : ICompositeNode { } +``` + +### 1.2 `CompositePartAttribute.AllowedChildTypes`(节点级约束) + +在现有 `CompositePartAttribute` 上新增可选属性,声明该节点允许的子节点实现类型: + +```csharp +// 新增到现有 CompositePartAttribute +public Type[]? AllowedChildTypes { get; set; } +``` + +**用法示例**: + +```csharp +[CompositePart("root", typeof(IMenuNode))] +public partial class MenuRoot : IMenuNode, ICompositeBuildable { } + +[CompositePart("panel", typeof(IMenuNode), ParentKey = "root", AllowedChildTypes = new[] { typeof(PanelNode), typeof(LeafNode) })] +public partial class PanelNode : IMenuNode, ICompositeBuildable { } + +[CompositePart("leaf", typeof(IMenuNode), ParentKey = "panel")] +// OK: LeafNode 的父节点 panel 允许 LeafNode +public partial class LeafNode : IMenuNode, ICompositeBuildable { } + +[CompositePart("section", typeof(IMenuNode), ParentKey = "root", AllowedChildTypes = new[] { typeof(SectionNode) })] +// ERROR: SectionNode 的父节点 root 未在 AllowedChildTypes 中声明 SectionNode +public partial class SectionNode : IMenuNode, ICompositeBuildable { } +``` + +**向后兼容**:`AllowedChildTypes = null`(默认)表示不限制——任何实现契约的类型都可作为子节点,与现有行为一致。 + +### 1.3 泛型变体同步 + +`CompositePartAttribute` 也新增 `AllowedChildTypes` 属性。`CompositeSchemaAttribute` 不需要泛型变体(它标注在契约类型本身上,无需泛型参数)。 + +--- + +## 二、诊断 ID + +| ID | 名称 | 严重性 | 归属 | 触发条件 | +|----|------|--------|------|----------| +| DP063 | CompositeTreeMaxDepthExceeded | Warning | SourceGenerators | 树的实际深度超过 `[CompositeSchema(MaxDepth)]` 声明值 | +| DP064 | CompositeChildTypeNotAllowed | Error | SourceGenerators | 子节点的实现类型不在父节点的 `AllowedChildTypes` 集合中 | +| DP065 | CompositeNodeCountExceeded | Warning | SourceGenerators | 契约的总节点数超过 `[CompositeSchema(MaxNodes)]` 声明值 | + +### 2.1 诊断消息格式 + +**DP063**: +- Title: "Composite tree max depth exceeded" +- Message: "Composite tree for contract '{0}' has depth {1}, exceeding the maximum depth of {2} declared by [CompositeSchema]. Consider flattening the tree structure or increasing MaxDepth." +- Description: "Excessively deep composite trees can cause stack overflows in recursive traversal. Review the tree structure or adjust the MaxDepth constraint." + +**DP064**: +- Title: "Composite child type not allowed by parent" +- Message: "Composite part '{0}' (type '{1}') is not in the AllowedChildTypes of its parent '{2}' (type '{3}'). Add '{1}' to the parent's AllowedChildTypes or change the ParentKey to a compatible parent." +- Description: "Parent-child type compatibility is enforced at compile time when AllowedChildTypes is specified. Ensure child implementation types are declared in the parent's AllowedChildTypes array." + +**DP065**: +- Title: "Composite node count exceeds limit" +- Message: "Composite contract '{0}' has {1} parts, exceeding the maximum of {2} declared by [CompositeSchema]. Consider splitting into multiple contracts or increasing MaxNodes." +- Description: "Excessive node counts may indicate architectural issues or cause performance problems in tree assembly and traversal." + +### 2.2 诊断报告位置 + +- **DP063**:报告在**最深的叶子节点**的 `[CompositePart]` 特性位置(指向造成超限的路径终点) +- **DP064**:报告在**子节点**的 `[CompositePart]` 特性位置(指向不被允许的子节点) +- **DP065**:报告在**契约类型**声明位置(`[CompositeSchema]` 标注的接口/类) + +--- + +## 三、生成器实现 + +### 3.1 数据流 + +现有管线不变。新增校验在 `ReportDiagnostics` 阶段执行(与现有 DP010–DP012 同层): + +``` +ForAttributeWithMetadataName("CompositePartAttribute") ← 现有 +ForAttributeWithMetadataName("CompositePartAttribute`1") ← 现有 + → Transform → CompositeRegistration[] ← 现有 + → Collect + Combine ← 现有 + → ReportDiagnostics ← 新增 schema 校验 + → Emit ← 现有 +``` + +### 3.2 深度计算 + +从 flat parent-key 映射计算树深度(复用现有 `BuildParentMap` 基础设施): + +```csharp +// 伪代码 +static int ComputeDepth(string key, IReadOnlyDictionary parentByKey) +{ + var depth = 1; // root = depth 1 + var children = entries.Where(e => e.ParentKey == key); + foreach (var child in children) + { + depth = Math.Max(depth, 1 + ComputeDepth(child.Key, parentByKey)); + } + return depth; +} +``` + +**优化**:使用 memoization 避免重复计算(节点数 N,O(N) 时间 + O(N) 空间)。 + +**安全**:循环检测已在 DP012 完成,深度计算不会无限递归。 + +### 3.3 父子类型兼容性校验 + +```csharp +// 伪代码 +foreach (var entry in registrations) +{ + if (entry.ParentKey is null) continue; + if (!entryByKey.TryGetValue(entry.ParentKey, out var parent)) continue; // DP011 已处理 + + var allowedTypes = parent.AllowedChildTypes; + if (allowedTypes is null || allowedTypes.Length == 0) continue; // 不限制 + + if (!allowedTypes.Contains(entry.ImplementationType)) + { + context.ReportDiagnostic(DP064, entry.Location, ...); + } +} +``` + +**注意**:`AllowedChildTypes` 存储的是 `Type[]`,在生成器中需要从特性参数提取 `INamedTypeSymbol` 并比较完全限定名。 + +### 3.4 节点计数 + +```csharp +// 伪代码 +var schemaAttr = contractType.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == "CompositeSchemaAttribute"); + +if (schemaAttr != null) +{ + var maxNodes = ExtractIntArgument(schemaAttr, "MaxNodes"); + if (maxNodes > 0 && registrations.Count > maxNodes) + { + context.ReportDiagnostic(DP065, contractTypeLocation, ...); + } +} +``` + +### 3.5 `CompositeSchemaAttribute` 提取 + +`CompositeSchemaAttribute` 标注在契约接口上,不在 `[CompositePart]` 的目标类上。生成器需要额外提取: + +1. 在 Transform 阶段,从 `INamedTypeSymbol contractType` 的 `GetAttributes()` 查找 `CompositeSchemaAttribute` +2. 提取 `MaxDepth` 和 `MaxNodes` 值 +3. 存入 `CompositeRegistration` 或单独的 schema 模型 + +**模型扩展**:在 `CompositeRegistration` 中新增 `int? SchemaMaxDepth` 和 `int? SchemaMaxNodes` 字段(nullable,null = 未标注 `[CompositeSchema]`)。或者使用单独的 `CompositeSchemaInfo` 模型,按契约分组。 + +选择后者(单独模型),避免每个 `CompositeRegistration` 重复存储: + +```csharp +private sealed record CompositeSchemaInfo( + ContractInfo Contract, + int? MaxDepth, + int? MaxNodes, + LocationInfo? Location); +``` + +在 Combine 阶段,按 Contract 分组提取 schema 信息,与 registrations 一起传入 ReportDiagnostics。 + +--- + +## 四、向后兼容性 + +| 变更 | 影响 | +|------|------| +| 新增 `CompositeSchemaAttribute` | 无影响——未标注时不触发任何新诊断 | +| `CompositePartAttribute.AllowedChildTypes` 新属性 | 无影响——`null` 默认值表示不限制 | +| 新增 DP063/DP064/DP065 | 无影响——仅在 opt-in 约束被声明时才报告 | + +**无破坏性变更**:所有现有 `[CompositePart]` 代码无需修改即可继续工作。 + +--- + +## 五、测试计划 + +### 5.1 生成器单元测试(Verify + 诊断断言) + +| 测试 | 验证点 | +|------|--------| +| `ReportsDp063_MaxDepthExceeded` | `[CompositeSchema(MaxDepth = 2)]` + 3 层树 → DP063 Warning | +| `ReportsDp063_NotExceeded_WhenWithinLimit` | `[CompositeSchema(MaxDepth = 5)]` + 3 层树 → 无 DP063 | +| `ReportsDp064_ChildTypeNotAllowed` | 父节点 `AllowedChildTypes = [A, B]`,子节点类型 C → DP064 Error | +| `ReportsDp064_Allowed_WhenTypeInList` | 父节点 `AllowedChildTypes = [A, B]`,子节点类型 B → 无 DP064 | +| `ReportsDp064_NotChecked_WhenAllowedChildTypesNull` | 父节点未设 `AllowedChildTypes` → 无 DP064(任意类型均可) | +| `ReportsDp065_NodeCountExceeded` | `[CompositeSchema(MaxNodes = 3)]` + 5 个节点 → DP065 Warning | +| `ReportsDp065_NotExceeded_WhenWithinLimit` | `[CompositeSchema(MaxNodes = 10)]` + 5 个节点 → 无 DP065 | +| `NoSchemaAttribute_NoNewDiagnostics` | 无 `[CompositeSchema]` + 深树 + 多节点 → 无 DP063/DP065 | +| `MaxDepth_ForestMode_ComputesPerTree` | 多根森林,最深树超过限制 → DP063 报告最深树 | +| `AllowedChildTypes_GenericAttribute` | 泛型 `[CompositePart]` + `AllowedChildTypes` → DP064 正确触发 | + +### 5.2 运行时测试 + +无需新增运行时测试——所有校验在编译期完成,运行时行为不变。 + +### 5.3 集成测试 + +新增一个使用 `[CompositeSchema]` + `AllowedChildTypes` 的集成测试项目,验证端到端生成 + 组装 + 遍历流程。 + +--- + +## 六、实现范围 + +| 文件 | 变更 | +|------|------| +| `DesignPatterns/Structural/CompositeSchemaAttribute.cs` | **新增** — 契约级特性 | +| `DesignPatterns/Structural/CompositePartAttribute.cs` | **修改** — 新增 `AllowedChildTypes` 属性 | +| `DesignPatterns/Structural/CompositePartAttribute.TContract.cs` | **修改** — 新增 `AllowedChildTypes` 属性 | +| `DesignPatterns.Diagnostics/DiagnosticIds.cs` | **修改** — 新增 DP063/DP064/DP065 常量 | +| `DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs` | **修改** — 新增 3 个描述符 | +| `DesignPatterns.SourceGenerators/Generators/CompositePartGenerator.cs` | **修改** — 提取 schema、深度计算、类型校验、节点计数 | +| `DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md` | **修改** — 新增 DP063/DP064/DP065 条目 | +| `tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.cs` | **修改** — 新增 10 个测试 | +| `tests/DesignPatterns.Tests/Structural/CompositeSchemaAttributeTests.cs` | **新增** — 特性构造测试 | +| `docs/Composite.md` | **修改** — 文档补充 schema 校验说明 | +| `docs/ROADMAP.md` | **修改** — 标记完成 | + +**PR 边界**:跨 Runtime + Diagnostics + SourceGenerators 三个模块。按 AGENTS.md 规范,应拆分为: +1. **Runtime PR**:`CompositeSchemaAttribute` + `CompositePartAttribute.AllowedChildTypes` +2. **Diagnostics + SourceGenerators PR**:DP063/DP064/DP065 + 生成器校验逻辑 + 测试 + +或合并为一个 PR(因为特性 + 诊断 + 校验紧密耦合,拆分后第一个 PR 无法独立验证)。选择合并。 + +--- + +## 七、探索价值 + +本 RFC 展示以下编译期 + 运行时协同技术: + +1. **从 flat 声明重建树拓扑**:源生成器从 `ParentKey` 字符串引用重建完整树结构,在编译期计算深度、验证类型兼容性——无需运行时反射或动态分析 +2. **opt-in 约束的渐进式类型安全**:`AllowedChildTypes` 允许用户逐步收紧类型约束,从「任意契约实现」到「特定实现类型集合」,编译期捕获设计错误 +3. **契约级 vs 节点级约束的分层**:`CompositeSchemaAttribute`(契约级全局约束)与 `AllowedChildTypes`(节点级局部约束)正交组合,覆盖不同粒度的校验需求 + +与 Stateless 等运行时库的对比:本方案将结构校验前移到编译期,运行时零开销;Stateless 等库在运行时动态验证状态机配置,有反射和异常成本。 diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.NoSchemaAttributeNoNewDiagnostics.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.NoSchemaAttributeNoNewDiagnostics.verified.txt new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.NoSchemaAttributeNoNewDiagnostics.verified.txt @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063ForestModeComputesPerTree.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063ForestModeComputesPerTree.verified.txt new file mode 100644 index 0000000..5036d71 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063ForestModeComputesPerTree.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP063, + Message: Composite tree for contract 'TestAssembly.IMenuNode' has depth 3, exceeding the maximum depth of 2 declared by [CompositeSchema]. Consider flattening the tree structure or increasing MaxDepth. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063MaxDepthExceeded.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063MaxDepthExceeded.verified.txt new file mode 100644 index 0000000..5036d71 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063MaxDepthExceeded.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP063, + Message: Composite tree for contract 'TestAssembly.IMenuNode' has depth 3, exceeding the maximum depth of 2 declared by [CompositeSchema]. Consider flattening the tree structure or increasing MaxDepth. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063NotExceededWhenWithinLimit.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063NotExceededWhenWithinLimit.verified.txt new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp063NotExceededWhenWithinLimit.verified.txt @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064AllowedWhenTypeInList.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064AllowedWhenTypeInList.verified.txt new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064AllowedWhenTypeInList.verified.txt @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064ChildTypeNotAllowed.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064ChildTypeNotAllowed.verified.txt new file mode 100644 index 0000000..27e45a7 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064ChildTypeNotAllowed.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP064, + Message: Composite part 'disallowed' (type 'DisallowedMenu') is not in the AllowedChildTypes of its parent 'root' (type 'RootMenu'). Add 'DisallowedMenu' to the parent's AllowedChildTypes or change the ParentKey to a compatible parent. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064NotCheckedWhenAllowedChildTypesNull.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064NotCheckedWhenAllowedChildTypesNull.verified.txt new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064NotCheckedWhenAllowedChildTypesNull.verified.txt @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064WithNonGenericAttribute.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064WithNonGenericAttribute.verified.txt new file mode 100644 index 0000000..27e45a7 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp064WithNonGenericAttribute.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP064, + Message: Composite part 'disallowed' (type 'DisallowedMenu') is not in the AllowedChildTypes of its parent 'root' (type 'RootMenu'). Add 'DisallowedMenu' to the parent's AllowedChildTypes or change the ParentKey to a compatible parent. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp065NodeCountExceeded.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp065NodeCountExceeded.verified.txt new file mode 100644 index 0000000..ea037e4 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp065NodeCountExceeded.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP065, + Message: Composite contract 'TestAssembly.IMenuNode' has 5 parts, exceeding the maximum of 3 declared by [CompositeSchema]. Consider splitting into multiple contracts or increasing MaxNodes. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp065NotExceededWhenWithinLimit.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp065NotExceededWhenWithinLimit.verified.txt new file mode 100644 index 0000000..ad47dbb --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.ReportsDp065NotExceededWhenWithinLimit.verified.txt @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.cs index e0192a4..b034cbc 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/CompositePartGeneratorTests.cs @@ -304,4 +304,457 @@ public sealed class SettingsMenu : IMenuNode, ICompositeBuildable return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); } + + [Fact] + public Task ReportsDp063MaxDepthExceeded() + { + var source = MenuUsings + """ + namespace TestAssembly; + + [CompositeSchema(MaxDepth = 2)] + public interface IMenuNode : ICompositeNode + { + string Title { get; } + } + """ + """ + + [CompositePart("root")] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("mid", ParentKey = "root")] + public sealed class MidMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Mid"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("leaf", ParentKey = "mid")] + public sealed class LeafMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Leaf"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp063NotExceededWhenWithinLimit() + { + var source = MenuUsings + """ + namespace TestAssembly; + + [CompositeSchema(MaxDepth = 5)] + public interface IMenuNode : ICompositeNode + { + string Title { get; } + } + """ + """ + + [CompositePart("root")] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("child", ParentKey = "root")] + public sealed class ChildMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Child"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp064ChildTypeNotAllowed() + { + var source = MenuUsings + """ + namespace TestAssembly; + + public interface IMenuNode : ICompositeNode + { + string Title { get; } + } + """ + """ + + [CompositePart("root", AllowedChildTypes = new[] { typeof(AllowedMenu) })] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("allowed", ParentKey = "root")] + public sealed class AllowedMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Allowed"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("disallowed", ParentKey = "root")] + public sealed class DisallowedMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Disallowed"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp064AllowedWhenTypeInList() + { + var source = MenuUsings + """ + namespace TestAssembly; + + public interface IMenuNode : ICompositeNode + { + string Title { get; } + } + """ + """ + + [CompositePart("root", AllowedChildTypes = new[] { typeof(ChildMenu) })] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("child", ParentKey = "root")] + public sealed class ChildMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Child"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp064NotCheckedWhenAllowedChildTypesNull() + { + var source = MenuUsings + MenuInterface + """ + + [CompositePart("root")] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("child", ParentKey = "root")] + public sealed class ChildMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Child"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp065NodeCountExceeded() + { + var source = MenuUsings + """ + namespace TestAssembly; + + [CompositeSchema(MaxNodes = 3)] + public interface IMenuNode : ICompositeNode + { + string Title { get; } + } + """ + """ + + [CompositePart("root")] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("a", ParentKey = "root")] + public sealed class MenuA : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "A"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("b", ParentKey = "root")] + public sealed class MenuB : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "B"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("c", ParentKey = "root")] + public sealed class MenuC : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "C"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("d", ParentKey = "root")] + public sealed class MenuD : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "D"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp065NotExceededWhenWithinLimit() + { + var source = MenuUsings + """ + namespace TestAssembly; + + [CompositeSchema(MaxNodes = 10)] + public interface IMenuNode : ICompositeNode + { + string Title { get; } + } + """ + """ + + [CompositePart("root")] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("a", ParentKey = "root")] + public sealed class MenuA : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "A"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task NoSchemaAttributeNoNewDiagnostics() + { + // Deep tree, many nodes, but no [CompositeSchema] → no DP063/DP065 + var source = MenuUsings + MenuInterface + """ + + [CompositePart("root")] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("l1", ParentKey = "root")] + public sealed class Level1 : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "L1"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("l2", ParentKey = "l1")] + public sealed class Level2 : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "L2"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("l3", ParentKey = "l2")] + public sealed class Level3 : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "L3"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp063ForestModeComputesPerTree() + { + // Two roots: one shallow, one deep → DP063 on the deep one + var source = MenuUsings + """ + namespace TestAssembly; + + [CompositeSchema(MaxDepth = 2)] + public interface IMenuNode : ICompositeNode + { + string Title { get; } + } + """ + """ + + [CompositePart("shallow-root")] + public sealed class ShallowRoot : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Shallow"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("deep-root")] + public sealed class DeepRoot : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Deep"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("mid", ParentKey = "deep-root")] + public sealed class MidMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Mid"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("leaf", ParentKey = "mid")] + public sealed class LeafMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Leaf"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp064WithNonGenericAttribute() + { + var source = MenuUsings + """ + namespace TestAssembly; + + public interface IMenuNode : ICompositeNode + { + string Title { get; } + } + """ + """ + + [CompositePart("root", typeof(IMenuNode), AllowedChildTypes = new[] { typeof(AllowedMenu) })] + public sealed class RootMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Root"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("allowed", typeof(IMenuNode), ParentKey = "root")] + public sealed class AllowedMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Allowed"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + + [CompositePart("disallowed", typeof(IMenuNode), ParentKey = "root")] + public sealed class DisallowedMenu : IMenuNode, ICompositeBuildable + { + private IReadOnlyList _children = System.Array.Empty(); + public string Title => "Disallowed"; + public IReadOnlyList Children => _children; + public void SetChildren(IReadOnlyList children) => _children = children; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("Menus.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } }