Outstanding work on the spike/source-generator branch before it is ready to merge to master and ship as v2. For historical bug/fix context see memory-bank/progress.md and git log.
Last updated: 2026-04-23.
Scope update (2026-04-23). All three Tier-2 features —
[ExpandFrom],[Translator]with DI,IPopcornBlindHandler<TFrom,TTo>— cleared from scope after use-case analysis showed each had a cleaner answer using patterns already native to ASP.NET Core + System.Text.Json. Each drop is documented with a recommended replacement in docs/MigrationV7toV8.md (§5, §7, §8). v2.0 is now feature-complete; the two remaining merge-gate items are both infrastructure (AOT CI + NuGet packaging).
- Core protocol (include parsing, attribute semantics, nested expansion, collections, dictionaries, enums, polymorphism-basic, circular refs, full nullability matrix): working.
- Tier-1 feature set — custom envelope +
UsePopcornExceptionHandler+[SubPropertyDefault]: shipped. - Test suite: 182 passing / 2 skipped / 0 failing in
Popcorn.FunctionalTests(2 remaining skips are the polymorphism dispatch feature — see Tier 2 section below). 19 passing inPopcorn.SourceGenerator.Tests. Zero CS86xx warnings in generated code. - AOT/trim smoke:
PopcornAotExamplebuilds withPublishAot=Trueand exercises a custom[PopcornEnvelope]shape. - Legacy reflection engine (
PopcornNetStandard*): still in the tree, unchanged. Planned removal after v2 ships side-by-side for a release or two.
All three planned Tier-2 features were considered and dropped after use-case analysis. The consistent finding: what v7 shipped as dedicated framework surface is, in v8, better served by patterns already native to ASP.NET Core + System.Text.Json. Each drop is documented in the migration guide with the recommended replacement:
[ExpandFrom]— use[Never]on internal source properties, a 3-line hand-written factory, orMapster.SourceGeneratorfor complex mapping. See MigrationV7toV8.md §7.[Translator]with DI — resolve at the endpoint (batchable, clear I/O boundaries, testable); computed properties still work for pure transforms. Serializing with injected services is an antipattern (N+1 queries, hidden I/O, scope threading complexity). See MigrationV7toV8.md §5.IPopcornBlindHandler<TFrom,TTo>— standardJsonConverter<T>registered onJsonSerializerOptions.Converterscovers the full use case and composes with Popcorn transparently. See MigrationV7toV8.md §8.
If a real consumer presents a concrete case that none of the replacement patterns cover, the specs live on in the git history and can be revived — but spec-driven shipping of features nobody has asked for is the shape of complexity we are deliberately shedding.
- Test ledger: 2 skipped in
PolymorphismTests.cs.PolymorphicCollection_EmitsDiscriminator_WhenConfigured: abstract/interface base + registered derived types via[JsonDerivedType]; generator needs to emit per-item type-dispatch.- "Unknown at build time" case: JSG008 diagnostic shipped (
ExpanderGenerator.cs:PolymorphicUnknownDescriptor). Generator now emits a warning when a member is typedobject, abstract class, or interface. 5 tests inEnvelopeDiagnosticsTests.cscover the positive and negative cases. Registered-derived support (the[JsonDerivedType]dispatch half) remains unimplemented.
- Scope: medium, mostly generator-side. Defer unless a consumer blocks on it.
- Factories — moot until deserialization ships.
[Factory]-tagged static method for instantiating types during read. - Deserialization — out of scope for v2.0. Generator currently emits write-only converters.
- Legacy
Dictionary<string, object>contexts — dropped (superseded by DI).
- 3-way baseline committed:
benchmarks/results/v2-baseline/. Covers Stj (reflection) vs Stj (source-gen) vs Popcorn (source-gen) vs legacyPopcornNetStandard(reflection) across SimpleModel, SimpleModelList[100], ComplexNestedModel, ComplexNestedModelList[25]. Three incremental generator optimizations landed after initial baseline capture — walk-through underopt-iterations/. - Headline: Popcorn source-gen beats legacy reflection in every scenario (3–8× for
All, ~5.8× forDefaulton ComplexModelList). Popcorn-default on ComplexModelList is ~10× faster / ~5× less alloc than STJ reflection. Popcorn-all on ComplexModelList is 0.87× time / 0.93× alloc — Popcorn is faster than STJ when emitting everything on nested data; legacy-all is 3.6× slower than STJ on the same shape. - Merge-gate item: closed. "Perf parity or better" was the load-bearing thesis claim; 3-way report confirms it — and the three in-generator optimizations tipped it from parity-to-STJ into better than STJ on complex nested lists.
.github/workflows/benchmarks.ymlruns on PR + push tomaster/spike/**.dotnet run -- ciinSerializationPerformanceruns 5 filtered benchmarks (SimpleJob — 3 warmup + 15 iterations, ~2 min wall time), then.github/scripts/compare-benchmark-ratios.pycomputes three load-bearing ratios (Popcorn / STJ-source-gen on the same shape) and compares againstbenchmarks/results/ci-baseline.json. Fails if any ratio regressed by more thanthresholdPercent(default 25%). Improvements > 25% print an informational note that reminds you to update the baseline.- Why ratios, not absolute timings: GitHub runners vary ±20–30% in wall-clock ns/op; ratios within a single run are stable because both numerator and denominator see the same noise. Gating on absolute times would be all false positives.
- Three baseline ratios:
SimpleModelList_PopcornAll_vs_Stj(worst-case 1.49),ComplexModelList_PopcornAll_vs_Stj(headline 0.87 — Popcorn is faster than STJ),ComplexModelList_PopcornDefault_vs_Stj(selectivity 0.11). - Update discipline: when a real optimization ships, re-run
cilocally and commit the new baseline in the same PR. When an intentional regression (new feature that trades perf for ergonomics) ships, bump the baseline upward. - Markdown BDN report + trx results surfaced via
$GITHUB_STEP_SUMMARY; raw BDN artifacts uploaded for 30 days.
.github/workflows/tests.ymlruns on PR + push tomaster/spike/**. Installs .NET 8.0 SDK, caches NuGet packages keyed on csproj hashes, runsdotnet teston bothPopcorn.FunctionalTests(182 passing / 2 skipped) andPopcorn.SourceGenerator.Tests(19 passing). trx logs uploaded as an artifact on failure. Concurrency-group cancels superseded runs.- Why separate from
aot-ci.yml: the AOT workflow needs the AOT toolchain (clang/zlib) + Docker; the test workflow should run faster and with fewer dependencies. Parallel jobs keep PR feedback tight. - Previously the 201 tests only ran on dev boxes — a regression in the generator or runtime could land on
spike/source-generatorwithout catching. Closed.
.github/workflows/aot-ci.ymlruns on PR + push tomaster/spike/**. Usesdocker/build-push-action@v5withtype=ghacache to builddotnet/PopcornAotExample/Dockerfile(context:dotnet/). Starts the container on port 8080, waits up to 60s for readiness, verifies all four endpoints end-to-end:/todos—Success:true,Id:1+Id:2present,IsComplete([Never]) absent./null—Data:nullin the envelope./sub—Id:1+ nestedToDoobject./boom— status500,Ok:false,Problempopulated with the exception message (exercises the exception middleware + generator-emitted custom-envelope error writer).
- On failure: dumps
docker logs. Always: stops the container. Concurrency-group cancels superseded runs on the same ref. - Endpoint assertions verified locally (2026-04-23) against the JIT-mode app; docker daemon wasn't available on the dev box so the container path will be first-exercised by CI itself.
- Merge-gate item: closed. Any future change that breaks the AOT code path will fail this job.
- Two-package design.
Skyward.Api.Popcorn.SourceGen.Shared(runtime attributes, envelopes, middleware — fromPopcorn.Shared.csproj) andSkyward.Api.Popcorn.SourceGen(analyzer-only, fromPopcorn.SourceGenerator.csproj). Side-by-side-installable with legacySkyward.Api.Popcornv7 because the IDs diverge. - Metadata shipped. Both csproj files carry
PackageId,Version=8.0.0-preview.1,Authors,Description,PackageTags,PackageProjectUrl,RepositoryUrl,PackageLicenseFile=LICENSE,PackageReadmeFile=README.md,Copyright. Both referenceMicrosoft.SourceLink.GitHubwithPublishRepositoryUrl=trueandEmbedUntrackedSources=true.SourceGenis markedDevelopmentDependency=true+SuppressDependenciesWhenPacking=trueso it flows analyzer-only and declares no runtime dependencies.SourceGen.SharedhasIncludeSymbols=true+SymbolPackageFormat=snupkg. - Analyzer packaging.
SourceGenembedsPopcorn.Shared.dllintoanalyzers/dotnet/cs/(required for Roslyn to resolve attribute symbols during generation). The separateSourceGen.Sharedpackage provides the runtime-visible copy underlib/netstandard2.0/. Consumers install both; they serve different layers. - CI workflow.
.github/workflows/main.ymlextended to pack+push both v8 packages alongside the legacy v7 pack steps on tag releases.fetch-depth: 0added so SourceLink can resolve commit hashes. - Verified locally.
dotnet packproducesSkyward.Api.Popcorn.SourceGen.Shared.8.0.0-preview.1.nupkg(16 KB,lib/netstandard2.0/Popcorn.Shared.dll+ deps) andSkyward.Api.Popcorn.SourceGen.8.0.0-preview.1.nupkg(38 KB,analyzers/dotnet/cs/containing both dlls, nolib/, no transitive deps). Snupkg generated for Shared. - Remaining to tag:
- Test install from a throwaway consumer project. Smoke-tested on 2026-04-23 using a
net9.0classlib with<PackageReference>to both packages from a local feed: packages restored, analyzer ran, generatedSmokeConsumerCarJsonConverter.g.cs+SystemCollectionsGenericListSmokeConsumerCarJsonConverter.g.cs+RegisterConverters.g.cs, STJ source generator picked up the emittedPop<Car>/Pop<List<Car>>types (visible inSmokeJsonContext.PopCar.g.cs/SmokeJsonContext.PopListCar.g.cs), build clean (0 errors, only informational JSG002 logs). - Update
docs/Releases.mdwith the preview entry. - Tag
8.0.0-preview.1, push tag, CI pushes to NuGet. (Operational — user decision to ship.)
- Test install from a throwaway consumer project. Smoke-tested on 2026-04-23 using a
- Merge-gate item: code-complete. Everything up to the actual
git tag+ push is done.
- docs/MigrationV7toV8.md shipped — covers attribute renames, dropped features (sorting/pagination/filtering/authorizers), DI replacement for
SetContext(dict), custom envelope + middleware forSetInspector(lambda), include-parameter wire-name contract, JSG008 documentation, rollback plan. - Decide concrete deprecation window for v7 packages: proposed "v7 remains on NuGet for at least one release after v8.0 ships; v7 gets a
<PackageReleaseNotes>banner pointing at MigrationV7toV8.md." - Update
Releases.md(currently empty of v8 entries) with an8.0.0-preview.1entry when it cuts.
dotnet/Examples/PopcornNet5Example/still references the v7 reflection engine (services.UsePopcorn((config) => config.UseDefaultConfiguration()),ExpandServiceFilter) and targetsnet5.0. Either port to v8 (minimal API +IPopcornAccessor+[JsonSerializable]context) or delete and rely solely ondotnet/PopcornAotExample/as the canonical example.- Why: leaving a v7-shaped example next to a v8 release will confuse new adopters.
Confirmed against ExpanderGenerator.cs on 2026-04-23.
- Pragma scope in generated converter files is slightly broad.
ExpanderGenerator.cs:908emits#pragma warning disable CS8619, CS8600, CS8601, CS8625at file scope. CS8619 / CS8625 are load-bearing (NRT-cast through generated code). CS8600 / CS8601 are pulled in defensively; could theoretically mask a real null bug introduced by a future generator change. Narrow to per-statement where feasible. - User-defined non-generic subclasses of Dictionary/IDictionary will crash the generator.
class SettingsDict : Dictionary<string, string> {}hasTypeArguments.Length == 0;ExpanderGenerator.cs:847accessesnamedDictionaryTypeNonNullable.TypeArguments[1]unguarded →IndexOutOfRangeException. No test hits it today. Fix: walk theIDictionary<K, V>interface chain forTypeArgumentsrather than reading the target type's own list. IsBlindSerializableTypeuses stringly-typed hashset lookups. Matches the pre-existing convention (NumberTypes,StringTypes,BoolTypes,IgnoreTypesall compared viaToDisplayString().Replace("?", "")). Fragile to Roslyn display-format changes but consistent. Future cleanup: replace all such lookups withSpecialType/ITypeSymbolidentity comparisons.- Cycle-safety analyzer is conservative on non-blind unregistered types.
IsNamedTypeCycleSafe(ExpanderGenerator.cs:133-164) treats any type NOT inallTypeNamesas cycle-safe. Correct today — unregistered user types fall through toJsonSerializer.Serializewhich doesn't touch Popcorn's HashSet. Revisit if a future change starts recursing through such types (e.g.IPopcornBlindHandlerlanding).
Three generator-level optimizations considered but not taken in the 2026-04 opt pass. Listed in rough order of expected payoff.
- Pre-encoded property names via
JsonEncodedText. STJ's own source-gen path does this; saves per-property UTF-16→UTF-8 encoding cost. Complicated by runtimePropertyNamingPolicy(the encoded form depends on options → forces per-options caching). Biggest remaining lever; would likely close most of the remainingSimpleModelList_PopcornAll1.40× gap vs raw STJ. - Skip the per-property include-match scan when
useAll && !hasNegations. Every property unconditionally emits under!allwith no negations, so the scan is pure overhead. A one-time check at the top of the body could bypass the per-property loop entirely. Moderate payoff onPopcorn_Allscenarios. - Hashtable-keyed include-list lookup. Current linear scan is O(n·m) in properties × include-list size. Marginal gain — include lists are typically small.
- Why: URLs have length limits;
GET /foo?include=[very,long,list,...]can blow past proxy limits. An alternative header carries the same grammar, parsed byPopcornAccessorwith header-first / query-fallback priority. - Design:
PopcornAccessor.PropertyReferencesgetter checksHttpContext.Request.Headers["POPCORN-INCLUDE"]first, falls back to query?include=. - Status: spec'd in
memory-bank/apiDesign.md, not started. Decision: implement in this spike, or defer to v2.1? - Scope: tiny. ~1 day including tests.
- Out of scope for v2.0. Note separately if a consumer requests it.
- Out of scope for this .NET spike. Protocol decisions on this branch constrain any future client, but we don't block on client work.
A defensible order that minimizes dependency chains and maximizes incremental merge-readiness:
- Publish a benchmark baseline. (Done.)
- Tier-2 scope cleanup. (Done — all three features dropped; see section above.)
- AOT CI job + NuGet packaging. Final two merge gates before v2.0 can ship.
- Polymorphism dispatch (if a consumer requests it; otherwise defer to v2.1).
- Header-based include (opportunistic; ship whenever convenient).
Adjust based on what any real consumer blocks on first.
- Published benchmark report. 3-way (Stj reflection vs Stj source-gen vs Popcorn source-gen vs legacy
PopcornNetStandard) committed underbenchmarks/results/v2-baseline/. - Fix the
Pop{X}Innerregression on nested-collection registrations. - CI job that publishes the AOT example and runs it in a container. Landed as
.github/workflows/aot-ci.yml. - NuGet packaging story for
Popcorn.SourceGenerator+Popcorn.Shared— two-package designSkyward.Api.Popcorn.SourceGen+Skyward.Api.Popcorn.SourceGen.Shared, 8.0.0-preview.1, verified locally. Operational tag+push remains. - v7→v8 migration guide (docs/MigrationV7toV8.md).
- JSG008 diagnostic for polymorphic unknown-at-build-time types.