Skip to content

Refuse open generic decoration and cross-wiring, and cut 1.0.0-rc9330 - #37

Merged
ipjohnson merged 5 commits into
mainfrom
feat/open-generic-diagnostics
Aug 14, 2026
Merged

Refuse open generic decoration and cross-wiring, and cut 1.0.0-rc9330#37
ipjohnson merged 5 commits into
mainfrom
feat/open-generic-diagnostics

Conversation

@ipjohnson

Copy link
Copy Markdown
Owner

Decoration and cross-wiring both replace a registration with a factory, and the container refuses a factory for an open generic service type — Open generic service type 'IRepository1[T]' requires registering an open generic implementation type`. Neither path said so, and the three shapes of the mistake failed in two different ways.

What was wrong

Shape Before
generic decorator, [Decorator] on class silently dropped — green build, decorator never runs
generic decorator, [Decorate] on module silently dropped
non-generic decorator on an unbound service CS7003 + CS1503 in generated code
[CrossWireService] on a generic type CS0246 + CS7003 in generated code

The two silent cases came from expansion: a generic decorator is expanded against the closed constructions a compilation registers, and an open generic registration closes nothing, so the declaration was dropped with nothing to say. The non-generic decorator never went through expansion at all, so nothing caught it and it reached emission carrying IStore<>. HasUnboundServiceType was written to describe exactly that case and was never consulted; it is now the guard.

What this adds

DM0013 — a service registered as an open generic cannot be decorated. Covers all three decorator shapes, whichever way the decorator was declared.

DM0014 — a generic type cannot be cross-wired. The whole registration is dropped rather than the cross-wired half, since keeping the implementation's own registration would leave the instance unreachable through any of its interfaces.

Deliberately not reported: a decorator naming a service the compilation does not register. Naming a service someone else registers is what [Decorate] exists for, so a diagnostic there would fire on the feature's primary use.

Docs

DecorateAttribute's service parameter was documented as "may be an open generic", which contradicted the README. The decorators guide described the failure as an InvalidOperationException from DecoratorHelper — that guard is unreachable from generated code, because expansion drops the decorator before anything is emitted, so it now points at DM0013.

Breaking

DecoratorExpansion.Expand takes an additional out IReadOnlyList<DecoratorModel> carrying what it refused. That is on the generator extension points, which the extending guide documents as unversioned. The public API snapshot is updated.

Verification

  • 710/710 unit tests on net8.0 and net10.0
  • 135 xUnit + 34 NUnit integration tests, unchanged
  • 17 new tests, including two asserting the generated code still compiles (via GeneratedAssembly.Create, which fails on any emit error — these would have failed before this change) and two negative tests guarding the open-generic-decorator-over-closed-registrations case and the cross-assembly case
  • Native AOT publish clean: zero trim/AOT warnings
  • Docs site builds with ignoreDeadLinks: false

Release

Second commit stamps 1.0.0-rc9330 and moves the Unreleased section under it, matching how rc9230 was cut. dotnet pack produces DependencyModules.Runtime.1.0.0-rc9330.nupkg. Merging this means pushing v1.0.0-rc9330 to publish.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Np5LZpJ2PPStWVcrNXfmD5

Ian Johnson and others added 5 commits August 14, 2026 12:21
…lently

Decoration and cross-wiring both replace a registration with a factory, and the
container refuses a factory for an open generic service type. Neither path said
so, and the two failed in different ways.

A generic decorator is expanded against the closed constructions a compilation
registers. An open generic registration closes nothing, so the expansion produced
no decorations and the declaration was dropped in silence. A non-generic decorator
named against an unbound service needed no expansion at all, so nothing caught it:
it reached emission carrying IStore<> and produced Decorate<IStore<>>, which is
CS7003 inside generated code. [CrossWireService] on a generic type was worse
again, leaking the type parameter into typeof(ILedger<T>) beside
GetRequiredService<Ledger<>>() for CS0246 and CS7003 together.

DM0013 covers all three decorator shapes, whichever way the decorator was
declared. DM0014 covers cross-wiring, dropping the whole registration rather than
the cross-wired half, since keeping the implementation's own registration would
leave the instance unreachable through any of its interfaces.

A decorator naming a service the compilation does not register stays quiet.
Naming a service someone else registers is what [Decorate] exists for, so
reporting there would fire on the feature's primary use.

HasUnboundServiceType was written to describe the CS7003 case and was never
consulted; it is now the guard. DecoratorExpansion.Expand takes an out parameter
carrying what it refused, which is a break on the generator extension points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np5LZpJ2PPStWVcrNXfmD5
The Unreleased section holds one change — the open generic refusals — so it is
stamped as rc9330 rather than left to grow.

VersionSuffix is the fallback local and CI builds use. A release still takes its
version from the tag, so cutting rc9330 means pushing v1.0.0-rc9330. Assembly and
file versions carry no prerelease part and are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np5LZpJ2PPStWVcrNXfmD5
…phabet

Four registrations that were silently not happening, and the diagnostics for two
more that were refused without saying so.

Attribute usages were compared as written, so a namespace-qualified spelling, a
global:: prefix or a using alias missed and the class went unregistered with no
diagnostic at all. Service attributes now resolve through the semantic model;
module attributes match on the name the usage ends in, which covers every
qualified form. A using alias of a module attribute is still not seen, because a
predicate that must stay syntax-only cannot resolve one, and that case surfaces as
CS0311 rather than silence.

Replace and Try act on a registration that has to already be there, and
registrations are emitted sorted by implementation type name. Named so the sort
put them first they ran before their target existed, so Replace replaced nothing,
added itself, and lost to the registration it meant to displace. They are emitted
after the plain Add registrations now, the rule that already put conditional
registrations last.

[Mock] registered its double unkeyed even when the parameter carried
[FromKeyedServices], leaving the keyed registration the consumer injects
untouched: the test held a double it believed was wired in while the service under
test kept the real implementation.

DM0015 reports an interceptor absent from members whose shape it cannot serve. The
sharpest form — an IInterceptor on an all-async service, where it never runs — was
invisible to the generator too, which discarded the model before anything could
report on it. DM0008 now says that one unsupported member costs the whole
interface, which the guide had implied otherwise.

CopyToOutputDirectory on a Compile item copies the source, and the metadata flowed
to every referencing project: 50 files of generator internals in consumers' bin
and publish output, this repository's own benchmarks included. The NuGet package
was never affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np5LZpJ2PPStWVcrNXfmD5
A generic implementation registers as an open generic and was refused outright,
because decoration cannot touch one and interception inherited the constraint
without needing it. Decoration rewrites a registration into a factory, which an
open generic service type cannot carry. Interception generates a type, and an open
generic implementation type is exactly what the container does accept.

The wrapper is now generic over the same parameters, and takes the implementation
by its own type rather than the service — asking for the service would resolve the
wrapper, which is registered as that service, and recurse. Its nested state classes
close the self reference over the outer parameters, which is CS0305 written bare.
DecoratorHelper.InterceptOpenGeneric swaps the registration and registers the
implementation alongside it, carrying lifetime and service key across, and is
idempotent so two modules carrying the same registration do not double-wrap.

A constrained type parameter is still refused. The wrapper would have to repeat the
constraint to reference the implementation, and there is no way to emit one; the
message says that rather than describing the old factory reason.

Verified under Native AOT, where the limit belongs to the container rather than to
this: a published binary closes an open generic over reference types only, and
throws for a value type argument. A plain [SingletonService] on a generic class
behaves identically, so interception adds no restriction. Both the AOT guide and
the interception guide now say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np5LZpJ2PPStWVcrNXfmD5
Interception refused a constrained generic implementation because the wrapper has
to repeat the constraints to reference what it wraps, and there was no way to emit
one. There is now: CSharpAuthor 1.1.1010 adds AddConstraint, which takes the parts
and writes them in the order C# requires.

Repository<T> where T : class, IEntity, new() is wrapped by
Repository_Intercepted<T> : IRepository<T> where T : class, IEntity, new(). struct
and unmanaged already guarantee a default constructor and Roslyn reports one for
them, so new() is dropped rather than repeated - writing it out is CS0451.

The method path moves onto the same model. Constraints were read into a rendered
string for generic methods and, once classes needed them too, would have been read
a second way for classes - two copies of the same precedence rules, including that
unmanaged has to be tested before struct because it implies it. TypeParameterReader
reads a parameter once for both, and the writer hands the parts to the library
rather than formatting a clause itself.

Verified by compiling and running the result: a service constrained to
class, IMarker, new() resolves as its wrapper and is intercepted, and one
constrained to struct does too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np5LZpJ2PPStWVcrNXfmD5
@ipjohnson
ipjohnson merged commit 12cf60c into main Aug 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant