Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,12 @@ section is the GitHub release's notes.
- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, or read back through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)).
- Analyzers: CMTK0006 now warns when a constructor sets a member only on another object it creates, `new Order(...) { Id = id }` or `other with { Id = id }`, which it took as setting its own. In a build that treats warnings as errors, that can report code that built before ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)).
- EF Core: a query filter that orders values your context holds without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`, builds again; EF works it out in memory by your `CompareTo`. With a lambda, EF orders those values in the database, and the filter still fails ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)).
- Analyzers: CMTK0005 now also warns at `Array.CreateInstance(typeof(OrderId), n)`, `Array.CreateInstanceFromArrayType(typeof(OrderId[]), n)` and `ArrayPool<OrderId>.Rent(n)`, which create default slots as `new OrderId[n]` does. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)).

### Fixed

- Analyzers: CMTK0005 no longer reports an array with a constant zero in any one dimension, such as `new OrderId[0, count]`, or a zero written as `0L` or, in a `stackalloc`, `(byte)0`: neither holds an element ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)).
- Analyzers: CMTK0006 no longer reports an explicit interface implementation that every constructor assigns through a cast, `((IHasId)this).Id = id` ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)).

### Fixed

- OpenAPI: each element of a list, an array or a dictionary of `Patch<T>` is described as a patch property is, the argument's schema admitting null, where it admitted anything. A nullable `Patch<T>?` is its argument's schema admitting null, where it was an empty required component; System.Text.Json reads a null there as a `null` patch, as it reads a property left out ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)).

## 2.3.0 — 2026-10-29
Expand Down
92 changes: 75 additions & 17 deletions src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
Expand All @@ -16,7 +17,11 @@ namespace CodoMetis.TypeKit.Analyzers;
/// CMTK0001 sees <c>default</c> written out, and none of these forms writes it: <c>new OrderId[n]</c>,
/// <c>stackalloc OrderId[n]</c>, <c>GC.AllocateUninitializedArray&lt;OrderId&gt;(n)</c>,
/// <c>GC.AllocateArray</c> and <c>Array.Resize</c> (measured 2026-09-28), which adds default slots
/// whenever the new size is larger, and the rule cannot tell whether it is. An array with initial
/// whenever the new size is larger, and the rule cannot tell whether it is. Also
/// <c>Array.CreateInstance(typeof(OrderId), n)</c>, <c>Array.CreateInstanceFromArrayType(typeof(OrderId[]), n)</c>
/// and <c>ArrayPool&lt;OrderId&gt;.Rent(n)</c> (#228): the first two only where a <c>typeof</c> names the element,
/// since a <c>Type</c> variable could be anything, and a rented array is reported as a fresh one, all
/// default, though a reused one holds what it was returned with. An array with initial
/// elements, a constant length or size of zero in any one dimension and a collection expression start
/// with no default element and are not reported. A warning, since filling such an array in a loop
/// straight after is correct code the rule cannot tell apart.
Expand Down Expand Up @@ -52,9 +57,10 @@ public override void Initialize(AnalysisContext context)

var gc = start.Compilation.GetTypeByMetadataName("System.GC");
var array = start.Compilation.GetTypeByMetadataName("System.Array");
var pool = start.Compilation.GetTypeByMetadataName("System.Buffers.ArrayPool`1");

start.RegisterOperationAction(operation => AnalyzeArrayCreation(operation, symbols), OperationKind.ArrayCreation);
start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols, gc, array), OperationKind.Invocation);
start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols, gc, array, pool), OperationKind.Invocation);

// IOperation has no public node for stackalloc; the syntax is read instead.
start.RegisterSyntaxNodeAction(node => AnalyzeStackAlloc(node, symbols), SyntaxKind.StackAllocArrayCreationExpression);
Expand All @@ -72,33 +78,85 @@ private static void AnalyzeArrayCreation(OperationAnalysisContext context, TypeK
if (Diagnose(context.ContainingSymbol, creation.Syntax, "An array created with a length", element, symbols) is { } diagnostic) context.Report(diagnostic);
}

private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols, INamedTypeSymbol? gc, INamedTypeSymbol? array)
private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols, INamedTypeSymbol? gc, INamedTypeSymbol? array, INamedTypeSymbol? arrayPool)
{
var invocation = (IInvocationOperation)context.Operation;
var method = invocation.TargetMethod;

if (method.TypeArguments.Length != 1) return;
string? form = null;
ITypeSymbol? element = null;

string? form = null;
var size = -1;

if (SymbolEqualityComparer.Default.Equals(method.ContainingType, gc) && AllocatingMethods.Contains(method.Name))
if (method.TypeArguments.Length == 1 && SymbolEqualityComparer.Default.Equals(method.ContainingType, gc) && AllocatingMethods.Contains(method.Name))
{
// (int length, bool pinned = false)
form = $"GC.{method.Name}";
element = method.TypeArguments[0];
}
else if (method.TypeArguments.Length == 1 && SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name == "Resize")
{
// (ref T[]? array, int newSize)
form = "Array.Resize";
element = method.TypeArguments[0];
}
else if (SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name is "CreateInstance" or "CreateInstanceFromArrayType")
{
form = $"GC.{method.Name}";
size = 0; // (int length, bool pinned = false)
// (Type elementType or arrayType, the lengths: one to three ints, int[] or long[], then int[] lowerBounds or nothing).
// Only a typeof names the element; a Type held in a variable could be anything.
var type = invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == 0)?.Value;
while (type is IConversionOperation conversion) type = conversion.Operand;

form = $"Array.{method.Name}";
element = (type as ITypeOfOperation)?.TypeOperand;
if (method.Name == "CreateInstanceFromArrayType") element = (element as IArrayTypeSymbol)?.ElementType;
}
else if (SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name == "Resize")
else if (method.Name == "Rent" && PoolOf(method, arrayPool) is { } pool)
{
form = "Array.Resize";
size = 1; // (ref T[]? array, int newSize)
// (int minimumLength): a fresh array from the pool holds nothing but default slots.
form = "ArrayPool.Rent";
element = pool.TypeArguments[0];
}

if (form is null) return;
if (form is null || element is null) return;

// A length of zero in any dimension creates no slot, as new T[0] and new T[0, n] do not.
if (Lengths(invocation).Any(length => IsZero(length.ConstantValue))) return;

if (Diagnose(context.ContainingSymbol, invocation.Syntax, form, element, symbols) is { } diagnostic) context.Report(diagnostic);
}

/// <summary>The <c>ArrayPool&lt;T&gt;</c> whose <c>Rent</c> <paramref name="method"/> is or overrides.</summary>
private static INamedTypeSymbol? PoolOf(IMethodSymbol method, INamedTypeSymbol? arrayPool)
{
for (var current = method; current is not null; current = current.OverriddenMethod)
{
if (SymbolEqualityComparer.Default.Equals(current.ContainingType.OriginalDefinition, arrayPool)) return current.ContainingType;
}

// A length of zero creates no slot, as new T[0] does not.
if (invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == size) is { } length && IsZero(length.Value.ConstantValue)) return;
return null;
}

if (Diagnose(context.ContainingSymbol, invocation.Syntax, form, method.TypeArguments[0], symbols) is { } diagnostic) context.Report(diagnostic);
/// <summary>
/// The length arguments of an allocating call: every <c>int</c> or <c>long</c> after the element, and the
/// elements of a <c>params</c> or written-out array of lengths, but not the lower bounds.
/// </summary>
private static IEnumerable<IOperation> Lengths(IInvocationOperation invocation)
{
foreach (var argument in invocation.Arguments)
{
if (argument.Parameter is not { } parameter || parameter.Name == "lowerBounds" || parameter.RefKind != RefKind.None) continue;

switch (parameter.Type)
{
case { SpecialType: SpecialType.System_Int32 or SpecialType.System_Int64 }:
yield return argument.Value;
break;

case IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Int32 or SpecialType.System_Int64 }
when argument.Value is IArrayCreationOperation { Initializer: { } lengths }:
foreach (var length in lengths.ElementValues) yield return length;
break;
}
}
}

private static void AnalyzeStackAlloc(SyntaxNodeAnalysisContext context, TypeKitSymbols symbols)
Expand Down
4 changes: 3 additions & 1 deletion src/CodoMetis.TypeKit.Analyzers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,9 @@ OrderId[] kept = [.. rows.Select(r => OrderId.From(r.Id))]; // built from valu
```

`stackalloc`, `GC.AllocateUninitializedArray`, `GC.AllocateArray` and `Array.Resize` are reported
too. `Array.Resize` adds default slots whenever the new size is larger, which the rule can't tell. A
too, and so are `Array.CreateInstance` and `Array.CreateInstanceFromArrayType` when a `typeof` names
the element, and `ArrayPool<T>.Rent`. A `Type` held in a variable could be anything, so it isn't
reported. `Array.Resize` adds default slots whenever the new size is larger, which the rule can't tell. A
constant length or size of zero creates no slot, as `new OrderId[0]` doesn't, and isn't reported. That
holds for any one dimension: `new OrderId[0, count]` holds no element either.

Expand Down
Loading
Loading