Skip to content
Draft
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: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="$(DotNetPackageVersionSpec)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="5.0.0" />
<PackageVersion Include="Microsoft.Build.Framework" Version="15.3.409" Condition="'$(MSBuildProjectName)' == 'IntelliTect.Coalesce.CodeGeneration.Vue'" />
<PackageVersion Include="Microsoft.Build.Utilities.Core" Version="15.3.409" Condition="'$(MSBuildProjectName)' == 'IntelliTect.Coalesce.CodeGeneration.Vue'" />
<PackageVersion Include="Microsoft.DotNet.Cli.Utils" Version="2.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="$(DotNetPackageVersionSpec)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(DotNetPackageVersionSpec)" />
Expand All @@ -62,6 +65,7 @@
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.12.7" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.13.20" />
<PackageVersion Include="SourceGenerator.Foundations" Version="2.0.14" />
<PackageVersion Include="System.Net.Http.Json" Version="8.0.1" />
<PackageVersion Include="TUnit" Version="1.34.5" />
<PackageVersion Include="TUnit.Core" Version="1.34.5" />
Expand Down
6 changes: 6 additions & 0 deletions docs/topics/dto-shapes-and-read-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ This is the layer that makes richer read DTOs practical without forcing every re
Generated contract shapes are not limited to a single nullability policy. A single source type can now describe stricter read-facing contracts and more permissive write- or patch-facing contracts from the same model.

That keeps generated interfaces and DTO classes aligned with their actual API semantics instead of forcing one nullability story onto every generated output.

## Same-project generated contracts are available during analysis

When generated contract shapes target the same project that declares the source type, Coalesce now materializes those generated contracts early enough for analysis and validation to see them consistently.

That keeps same-project generated interfaces and classes usable without introducing duplicate-member diagnostics or forcing consumers to move those shapes into a different project.
15 changes: 15 additions & 0 deletions src/IntelliTect.Coalesce.Analyzer.Tests/AnalyzerTypeAliases.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
extern alias CoalesceAnalyzer;

global using AttributeUsageAnalyzer = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.AttributeUsageAnalyzer;
global using Coalesce0001_InvalidPermissionLevel = CoalesceAnalyzer::Coalesce0001_InvalidPermissionLevel;
global using Coalesce0201_MissingFileTypeAttributeCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.Coalesce0201_MissingFileTypeAttributeCodeFixProvider;
global using Coalesce1001_SimplifyItemResult = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.Coalesce1001_SimplifyItemResult;
global using Coalesce1001_SimplifyItemResultCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.Coalesce1001_SimplifyItemResultCodeFixProvider;
global using Coalesce0005_UnexposedSecondaryAttributeCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.Coalesce0005_UnexposedSecondaryAttributeCodeFixProvider;
global using CS0457_AmbiguousItemResultConversionCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Fixers.CS0457_AmbiguousItemResultConversionCodeFixProvider;
global using GeneratedApiSurfaceSourceGenerator = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.SourceGenerators.GeneratedApiSurfaceSourceGenerator;
global using GeneratedContractShapeSourceGenerator = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.SourceGenerators.GeneratedContractShapeSourceGenerator;
global using GetQueryOrderingAnalyzer = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.GetQueryOrderingAnalyzer;
global using InvalidBehaviorsOverrideWithDenyAllAnalyzer = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.InvalidBehaviorsOverrideWithDenyAllAnalyzer;
global using RemoveAttributeCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.RemoveAttributeCodeFixProvider;
global using SecurityBypassAnalyzer = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.SecurityBypassAnalyzer;
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Microsoft.CodeAnalysis.Testing;
using IntelliTect.Coalesce.Analyzer.Fixers;

namespace IntelliTect.Coalesce.Analyzer.Tests;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#nullable enable

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using System.Collections.Immutable;
using System.Text;

namespace IntelliTect.Coalesce.Analyzer.Tests;

public class GeneratedApiSurfaceSourceGeneratorTests
{
[Test]
public async Task GeneratesSourcesFromSnapshotAdditionalFile()
{
var compilation = CreateCompilation(
"GeneratedApiSurfaceConsumer",
("Consumer.cs",
"""
namespace Demo;

public class Consumer
{
public Demo.Generated.PersonDto Contract { get; set; } = new();
}
""")
);

var snapshot = new InMemoryAdditionalText(
"/tmp/obj/coalesce-generated-csharp.json",
"""
{
"Models/Generated/PersonDto.g.cs": "namespace Demo.Generated { public class PersonDto { public string Name { get; set; } = string.Empty; } }"
}
""");

var updatedCompilation = RunGenerator(compilation, [snapshot], out var diagnostics);

await Assert.That(diagnostics).IsEmpty();
await Assert.That(updatedCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty();
await Assert.That(updatedCompilation.SyntaxTrees.Any(tree => tree.FilePath.Contains("Models/Generated/PersonDto.g.cs", StringComparison.Ordinal))).IsTrue();
}

[Test]
public async Task ReportsDiagnosticForInvalidSnapshot()
{
var compilation = CreateCompilation(
"GeneratedApiSurfaceConsumer",
("Consumer.cs", "namespace Demo; public class Consumer { }")
);

var snapshot = new InMemoryAdditionalText(
"/tmp/obj/coalesce-generated-csharp.json",
"{ invalid json }");

RunGenerator(compilation, [snapshot], out var diagnostics);

await Assert.That(diagnostics.Select(d => d.Id)).Contains("COALESCESG001");
}

[Test]
public async Task ReportsDiagnosticWhenGeneratedFilesAreStillCompiledFromDisk()
{
var compilation = CreateCompilation(
"GeneratedApiSurfaceConsumer",
("Consumer.cs", "namespace Demo; public class Consumer { }"),
("/repo/Models/Generated/PersonDto.g.cs", "namespace Demo.Generated { public class PersonDto { } }")
);

var snapshot = new InMemoryAdditionalText(
"/tmp/obj/coalesce-generated-csharp.json",
"""
{
"Models/Generated/PersonDto.g.cs": "namespace Demo.Generated { public class PersonDto { } }"
}
""");

RunGenerator(compilation, [snapshot], out var diagnostics);

await Assert.That(diagnostics.Select(d => d.Id)).Contains("COALESCESG002");
}

private static CSharpCompilation CreateCompilation(
string assemblyName,
params (string Path, string Source)[] sources)
{
var syntaxTrees = sources
.Select(source => CSharpSyntaxTree.ParseText(
SourceText.From(source.Source, Encoding.UTF8),
new CSharpParseOptions(LanguageVersion.Preview),
path: source.Path))
.ToArray();

return CSharpCompilation.Create(
assemblyName,
syntaxTrees,
GetMetadataReferences(),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable));
}

private static CSharpCompilation RunGenerator(
CSharpCompilation compilation,
ImmutableArray<AdditionalText> additionalTexts,
out ImmutableArray<Diagnostic> diagnostics)
{
var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options;
GeneratorDriver driver = CSharpGeneratorDriver.Create(
generators: [new GeneratedApiSurfaceSourceGenerator().AsSourceGenerator()],
additionalTexts: additionalTexts,
parseOptions: parseOptions);

driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out diagnostics);
return (CSharpCompilation)updatedCompilation;
}

private static List<MetadataReference> GetMetadataReferences()
=> AppDomain.CurrentDomain.GetAssemblies()
.Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location))
.Select(assembly => (MetadataReference)MetadataReference.CreateFromFile(assembly.Location))
.Append(MetadataReference.CreateFromFile(typeof(object).Assembly.Location))
.Distinct(MetadataReferencePathComparer.Instance)
.ToList();

private sealed class InMemoryAdditionalText(string path, string content) : AdditionalText
{
public override string Path { get; } = path;

public override SourceText GetText(CancellationToken cancellationToken = default)
=> SourceText.From(content, Encoding.UTF8);
}

private sealed class MetadataReferencePathComparer : IEqualityComparer<MetadataReference>
{
public static MetadataReferencePathComparer Instance { get; } = new();

public bool Equals(MetadataReference? x, MetadataReference? y)
=> StringComparer.OrdinalIgnoreCase.Equals((x as PortableExecutableReference)?.FilePath, (y as PortableExecutableReference)?.FilePath);

public int GetHashCode(MetadataReference obj)
=> StringComparer.OrdinalIgnoreCase.GetHashCode((obj as PortableExecutableReference)?.FilePath ?? string.Empty);
}
}
Loading
Loading