diff --git a/src/Marten.AotSmoke/Marten.AotSmoke.csproj b/src/Marten.AotSmoke/Marten.AotSmoke.csproj
index fcd3133345..0932706288 100644
--- a/src/Marten.AotSmoke/Marten.AotSmoke.csproj
+++ b/src/Marten.AotSmoke/Marten.AotSmoke.csproj
@@ -40,6 +40,12 @@
+
+
diff --git a/src/Marten.AotSmoke/Program.cs b/src/Marten.AotSmoke/Program.cs
index 9fc613dee3..db7071ff76 100644
--- a/src/Marten.AotSmoke/Program.cs
+++ b/src/Marten.AotSmoke/Program.cs
@@ -1,4 +1,6 @@
-// AOT smoke test (marten#4349 / JasperFx/jasperfx#213).
+// AOT smoke test (marten#4349 / JasperFx/jasperfx#213) plus
+// dispatcher-coverage regression guard (marten#4486 / marten#4471
+// projection audit).
//
// This program touches a representative cross-section of the AOT-clean
// Marten surface for a Static-TypeLoadMode consumer. The csproj sets
@@ -12,11 +14,29 @@
// - services.AddMarten(opts => { ... }) — the DI-facing entry point.
// - One document type with an Id property — exercises the closed-shape
// IDocumentStorage / IIdentification path (post-#4404, no Roslyn emit).
-// - One event type + one SingleStreamProjection<,> registration — the
-// primary projection-apply surface that Marten 9 routes through
-// JasperFx.Events.SourceGenerator's [GeneratedEvolver] discovery
-// (Options.Projections.DiscoverGeneratedEvolvers is called at startup
-// in DocumentStore.cs).
+// - Four projection shapes covering both JasperFx.Events.SourceGenerator
+// emission patterns × Apply-vs-Evolve method conventions:
+//
+// Pattern A — projection subclass; SG emits a partial declaration
+// merged into the user's class.
+// SmokeProjection — explicit Evolve override (deliberate SG bypass)
+// ApplyProjection — conventional Apply method (SG dispatches)
+//
+// Pattern B — self-aggregating aggregate; SG emits a sibling
+// XEvolver class plus [assembly: GeneratedEvolver(typeof(X),
+// typeof(XEvolver))] registration. Aggregate type does NOT need
+// to be partial.
+// SelfAggregatingApply — conventional Apply method on the aggregate
+// SelfAggregatingEvolve — explicit Evolve method on the aggregate
+//
+// ProjectionOptions.SingleStreamProjection() calls
+// source.AssembleAndAssertValidity() at registration time. Post-#276
+// that throws InvalidProjectionException whenever the SG didn't emit
+// a dispatcher for the shape — so a regression in the SG's discovery
+// rules trips at `dotnet run` here and the program exits non-zero.
+// (The build itself doesn't catch a missing emission — the SG silently
+// skips when its preconditions don't hold; the runtime check is the
+// sentinel.)
// - StoreOptions.GeneratedCodeMode = TypeLoadMode.Static — pins the
// consumer profile that AOT-publishing apps would set.
// - Host.CreateApplicationBuilder + Build + DI resolution of
@@ -31,6 +51,9 @@
// - JasperFx.RuntimeCompiler / services.AddRuntimeCompilation() — Marten 9
// retired that path (#4454 / #4461). This smoke deliberately does not
// pull it back in.
+// - Exhaustive enumeration of every projection shape in the test
+// libraries — the static inventory at audit/projections-inventory.md
+// covers that. This program is the regression sentinel.
using JasperFx.CodeGeneration;
using JasperFx.Events;
@@ -75,7 +98,30 @@
// discovery (DocumentStore.cs calls DiscoverGeneratedEvolvers at
// startup post-#4454). Inline lifecycle keeps the smoke test free of
// the async-daemon surface, which has its own AOT story.
+ //
+ // Each Add<>/Snapshot<> call below routes through
+ // ProjectionOptions.SingleStreamProjection(), which calls
+ // source.AssembleAndAssertValidity() and throws post-#276 if the SG
+ // didn't emit a dispatcher for the shape. Build failure here is the
+ // regression-guard signal (marten#4486).
+
+ // Pattern A — projection subclass with deliberate Evolve override
+ // (the user takes responsibility for dispatch; no SG emission expected).
opts.Projections.Add(ProjectionLifecycle.Inline);
+
+ // Pattern A — projection subclass with conventional Apply method
+ // (SG dispatches via partial-class merge).
+ opts.Projections.Add(ProjectionLifecycle.Inline);
+
+ // Pattern B — self-aggregating aggregate with conventional Apply
+ // method on the aggregate type itself. SG dispatches via a sibling
+ // SelfAggregatingApplyEvolver class plus assembly attribute.
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
+
+ // Pattern B — self-aggregating aggregate with explicit Evolve method
+ // on the aggregate type itself (still SG-dispatched because the
+ // emission targets the aggregate's Evolve signature directly).
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
});
using var host = builder.Build();
@@ -109,3 +155,52 @@ public override SmokeDoc Evolve(SmokeDoc? snapshot, Guid id, IEvent e)
return snapshot;
}
}
+
+// Pattern A — projection subclass with conventional Apply method. The SG
+// emits a partial declaration containing an Evolve override that dispatches
+// `e.Data` against Apply(SmokeEvent, ApplyDoc). Source MUST be partial; if
+// it isn't, the SG silently skips emission (no CS0260 — the SG checks for
+// partial first) and the runtime fail-fast in
+// JasperFxAggregationProjectionBase.AssembleAndAssertValidity() throws
+// InvalidProjectionException at Projections.Add<>() time. The program's
+// non-zero exit is therefore the regression-guard signal — the build alone
+// does not catch this.
+public sealed partial class ApplyDoc
+{
+ public Guid Id { get; set; }
+ public int Count { get; set; }
+}
+
+public sealed partial class ApplyProjection : SingleStreamProjection
+{
+ public void Apply(SmokeEvent ev, ApplyDoc snapshot) => snapshot.Count += ev.Delta;
+}
+
+// Pattern B — self-aggregating aggregate with conventional Apply method
+// directly on the aggregate type. The SG emits a sibling
+// SelfAggregatingApplyEvolver class plus an
+// [assembly: GeneratedEvolver(typeof(SelfAggregatingApply),
+// typeof(SelfAggregatingApplyEvolver))] registration. The aggregate type
+// does NOT need to be partial — the dispatcher is independent of the
+// source declaration.
+public sealed class SelfAggregatingApply
+{
+ public Guid Id { get; set; }
+ public int Count { get; set; }
+ public void Apply(SmokeEvent ev) => Count += ev.Delta;
+}
+
+// Pattern B — self-aggregating aggregate with explicit Evolve method
+// directly on the aggregate. SG emission still applies; the Evolve method
+// is the dispatch entry point the emitted XEvolver class calls.
+public sealed class SelfAggregatingEvolve
+{
+ public Guid Id { get; set; }
+ public int Count { get; set; }
+
+ public SelfAggregatingEvolve Evolve(IEvent e)
+ {
+ if (e.Data is SmokeEvent inc) Count += inc.Delta;
+ return this;
+ }
+}