diff --git a/.agents/skills/engineering-workflow/references/design-decisions.md b/.agents/skills/engineering-workflow/references/design-decisions.md
index 6c540f6..8087728 100644
--- a/.agents/skills/engineering-workflow/references/design-decisions.md
+++ b/.agents/skills/engineering-workflow/references/design-decisions.md
@@ -31,9 +31,14 @@ Four places, each with a different job — don't blur them together:
shapes, not shipped ones) — when a doc is describing a target rather than
current reality, keep that distinction explicit rather than letting a
doc quietly drift from "intent" to "fact" without the code to back it.
-- **`docs/adr/*.md`** — the actual decision record: numbered, permanent,
- immutable once accepted. This is where "what was decided and why, and
- what was rejected" actually lives — see **Writing an ADR** below and
+- **`docs/adr/*.md`** — the actual decision record: numbered, permanent.
+ Its original Decision/Rationale/Consequences text is immutable once
+ accepted — never rewritten to look like it was always this way — but a
+ correction or extension discovered later gets recorded as a dated
+ Amendment appended to the same ADR, rather than either editing the
+ original text or opening a new ADR for something that isn't actually a
+ reversal. This is where "what was decided and why, and what was
+ rejected" actually lives — see **Writing an ADR** below and
`docs/adr/README.md` for the numbering/status mechanics.
`docs/adr/0000-adr-template.md` is the template to copy for every new
ADR. An ADR is not a plan — it doesn't get a task checklist or a
@@ -140,20 +145,38 @@ a short one, not a skipped one:
5. Update the relevant `docs/*.md` topic doc to reflect the resulting
current (or intended, per the pre-implementation note above) state,
linking back to the ADR rather than re-explaining the reasoning.
-6. If this ADR changes direction from an earlier one, set the earlier
- ADR's `Status` to `Superseded by ADR-XXXX` — don't edit its Decision/
- Rationale/Consequences, an accepted ADR is a historical record, not a
- living doc.
+6. If this ADR changes direction from an earlier one — its core Decision
+ Outcome is being replaced, not just corrected or extended — set the
+ earlier ADR's `Status` to `Superseded by ADR-XXXX` — don't edit its
+ Decision/Rationale/Consequences in place for a change this size; write
+ the new decision as its own ADR instead.
+7. If implementation or review surfaces a correction, clarification, or
+ extension to an already-`Accepted` ADR's decision — not a reversal of
+ it — record it as a dated `## Amendment N (YYYY-MM-DD):
` section
+ appended to that same ADR, rather than opening a new one. This is the
+ established pattern in this repo (see ADR-0022's Amendments 1-6 for
+ real examples: a caching-interaction clarification, a reverted feature
+ attempt, a gap a later PR review caught, a test-infrastructure decision
+ changed after repeated CI failures) — an Accepted ADR's *original*
+ Decision/Rationale/Consequences text stays exactly as written (that's
+ the "immutable historical record" property that matters: what was
+ decided, and why, at the time, is never rewritten to look like it was
+ always this way), but the ADR as a whole is allowed to grow dated
+ Amendment sections that record what was learned afterward. Reach for
+ step 6 (a full new ADR, superseding this one) only when the core
+ decision itself is being reversed or replaced — a genuinely different
+ answer to the same question, not a correction to how the original
+ answer was implemented or verified.
Keep the ADR itself to decision content — Status, Decision, Rationale (or
Context/Decision Drivers/Considered Options/Decision Outcome for a deep
-dive that needs the fuller shape), Consequences. Resist the urge to also
-list every file that'll change or write a task checklist inside it; once
-an ADR is `Accepted`, that kind of content goes stale the moment
-implementation starts diverging from the plan in some small way, and
-you're stuck either leaving the ADR wrong or editing a record that's
-supposed to be immutable. That content belongs in a plan instead — see
-below.
+dive that needs the fuller shape), Consequences, and any Amendments per
+step 7 above. Resist the urge to also list every file that'll change or
+write a task checklist inside it; once an ADR is `Accepted`, that kind of
+content goes stale the moment implementation starts diverging from the
+plan in some small way, and you're stuck either leaving the ADR wrong or
+editing content that's supposed to stay as originally written. That
+content belongs in a plan instead — see below.
A design dive — light or deep — that never produces an ADR hasn't actually
finished. The brainstorm (for a deep dive) or the quick judgment call (for
diff --git a/.gitignore b/.gitignore
index 4966531..89e618a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -435,6 +435,10 @@ FodyWeavers.xsd
.idea
+# Local NuGet feed populated by Compono.XunitV3.SampleTests' pre-restore pack step
+# (test/Compono.XunitV3.SampleTests/nuget.config) - regenerated on every restore, never committed.
+.local-nuget-feed/
+
# macOS
.DS_Store
.DS_Store?
diff --git a/AGENTS.md b/AGENTS.md
index c50cd92..ccd138f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -142,9 +142,13 @@ list). The load-bearing ones:
## The non-negotiables
- Architecture is ADR-driven: every non-trivial design decision gets an
- ADR in `docs/adr/` (immutable once `Accepted`) before code is written
- against it. Don't silently evolve architecture in code — see
- `references/design-decisions.md`.
+ ADR in `docs/adr/` before code is written against it. An ADR's original
+ Decision/Rationale/Consequences text is immutable once `Accepted` —
+ never rewritten in place — but a correction or extension found later is
+ recorded as a dated Amendment appended to that same ADR (see ADR-0022's
+ Amendments for real examples); reserve superseding with a whole new ADR
+ for an actual reversal of the core decision. Don't silently evolve
+ architecture in code — see `references/design-decisions.md`.
- Keep PRs scoped to one decision or one feature — no bundling an
unrelated fix or refactor. If a plan is phased, that's one PR per
phase, and the prior phase's status/PR-merge state should be current
diff --git a/Directory.Packages.props b/Directory.Packages.props
index fc6f54b..bc1e30e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,6 +16,10 @@
+
+
+
diff --git a/src/Compono.XunitV3/ComposeAttribute.cs b/src/Compono.XunitV3/ComposeAttribute.cs
index 18347cf..34360da 100644
--- a/src/Compono.XunitV3/ComposeAttribute.cs
+++ b/src/Compono.XunitV3/ComposeAttribute.cs
@@ -120,8 +120,14 @@ public int Seed
/// parameter, more than one Compose-family attribute, or more than one [Shared] parameter
/// of the same type); too many inline values were supplied; a supplied inline value is
/// for a non-nullable parameter or has a type not assignable to its
- /// parameter; or composition itself fails for a parameter (propagated un-wrapped from the
- /// pipeline).
+ /// parameter; or composition itself fails for a parameter - a new
+ /// propagates whose is the pipeline's original message with the
+ /// row's seed appended and whose is copied through
+ /// from that original unchanged ( if the original had none). This new
+ /// exception's own is the pipeline's original
+ /// itself, not that original's
+ /// - so a provider failure's chain is wrapper → original composition exception → the provider's
+ /// own thrown exception, one level deeper than the original throw.
///
///
/// is deliberately never used to register a composed value.
@@ -224,12 +230,12 @@ public override ValueTask> GetData(MethodInf
if (i < _inlineValues.Length)
{
var value = _inlineValues[i];
- parameter.ShareExplicitInvoker(row, parameter.Descriptor, value);
+ InvokeWithSeedOnFailure(() => parameter.ShareExplicitInvoker(row, parameter.Descriptor, value), row.Seed);
values[i] = value;
}
else
{
- values[i] = parameter.ResolveSharedInvoker(row, parameter.Descriptor);
+ values[i] = InvokeWithSeedOnFailure(() => parameter.ResolveSharedInvoker(row, parameter.Descriptor), row.Seed);
}
}
@@ -243,7 +249,7 @@ public override ValueTask> GetData(MethodInf
values[i] = i < _inlineValues.Length
? _inlineValues[i]
- : parameter.ResolveInvoker(row, parameter.Descriptor);
+ : InvokeWithSeedOnFailure(() => parameter.ResolveInvoker(row, parameter.Descriptor), row.Seed);
}
// Assembled in method declaration order - binding order (shared first, then the rest) and
@@ -291,4 +297,39 @@ private Composer BuildComposer() => Composer.Create(builder =>
// (ADR-0022's Seed Policy and Reporting) - so every failure category ends the same way, whether
// Compono.XunitV3 constructed the message or the pipeline did.
private static string AppendSeed(string message, int seed) => $"{message}\n\nSeed: {seed}";
+
+ // A genuine composition failure (PR #26 review; ADR-0022 Amendment 5) propagates un-wrapped from
+ // the pipeline otherwise, and CompositionException.Message alone never carries the seed for that
+ // case (only exception.Diagnostic does, via its own ToString(), when Diagnostic is even present -
+ // a plain-message CompositionException, e.g. a generated HashSet/Dictionary collection plan's
+ // unique-value-exhaustion path, has no Diagnostic at all and would otherwise escape with no seed
+ // whatsoever - PR #26 review, third round) - a real test runner's failure display shows Message,
+ // not Diagnostic.ToString(), so the pasteable-seed promise wasn't actually visible there.
+ // CompositionException.WithSeedInMessage rewrites Message to include it, handling both shapes
+ // uniformly, while preserving Diagnostic (null or not) and the original exception as
+ // InnerException unchanged - so every CompositionException is caught here, not just diagnosed
+ // ones.
+ private static TResult InvokeWithSeedOnFailure(Func compose, int seed)
+ {
+ try
+ {
+ return compose();
+ }
+ catch (CompositionException exception)
+ {
+ throw CompositionException.WithSeedInMessage(exception, seed);
+ }
+ }
+
+ private static void InvokeWithSeedOnFailure(Action compose, int seed)
+ {
+ try
+ {
+ compose();
+ }
+ catch (CompositionException exception)
+ {
+ throw CompositionException.WithSeedInMessage(exception, seed);
+ }
+ }
}
diff --git a/src/Compono/CompositionException.cs b/src/Compono/CompositionException.cs
index cd70f18..c9a6aa5 100644
--- a/src/Compono/CompositionException.cs
+++ b/src/Compono/CompositionException.cs
@@ -21,18 +21,6 @@ public sealed class CompositionException : Exception
///
public CompositionDiagnostic? Diagnostic { get; }
- // Identifies which CompositionContext instance's own BuildException produced this exception - an
- // opaque identity token (CompositionContext.Identity), not the context itself (PR #17 review):
- // storing the actual CompositionContext would keep its whole object graph alive for as long as a
- // consumer or test runner retains this exception - its configured IServiceProvider, registration
- // factory closures, scope-held shared values, and trace buffer, none of which this comparison
- // needs. A bare bool couldn't distinguish "some context produced this" from "this context's active
- // nested resolution produced it" either (a registration factory can capture and invoke an entirely
- // different Composer and let that composer's own pipeline-diagnosed exception escape, or rethrow
- // one it captured earlier) - InvokeFactory's catch guard (docs/adr/0010) needs identity, just not
- // by holding the whole context to get it.
- internal object? DiagnosingContextIdentity { get; private init; }
-
///
/// Creates a with no structured .
///
@@ -73,6 +61,56 @@ public CompositionException(CompositionDiagnostic diagnostic, Exception innerExc
Diagnostic = diagnostic;
}
+ ///
+ /// Creates a copy of whose has a
+ /// "Seed: <value>" line appended, so a consumer building custom composition-failure
+ /// tooling (e.g. a test-framework integration reporting a reproducible seed) can surface it
+ /// without needing to be present - already
+ /// renders its own "Seed:" line via when it's
+ /// there, but not every has one (a plain
+ /// , e.g. a generated collection plan's
+ /// unique-value-exhaustion failure, has none).
+ ///
+ /// The exception to copy and append the seed to.
+ /// The seed value to append.
+ ///
+ /// A new whose is
+ /// 's, unchanged ( stays ),
+ /// and whose is itself - so a
+ /// provider failure's full chain becomes this new exception, then ,
+ /// then whatever 's own was (if
+ /// any), one level deeper than the original throw.
+ ///
+ /// is .
+ public static CompositionException WithSeedInMessage(CompositionException original, int seed)
+ {
+ ArgumentNullException.ThrowIfNull(original);
+
+ var message = $"{original.Message}\n\nSeed: {seed}";
+ return new CompositionException(message, original.Diagnostic, original) { DiagnosingContextIdentity = original.DiagnosingContextIdentity };
+ }
+
+ // Identifies which CompositionContext instance's own BuildException produced this exception - an
+ // opaque identity token (CompositionContext.Identity), not the context itself (PR #17 review):
+ // storing the actual CompositionContext would keep its whole object graph alive for as long as a
+ // consumer or test runner retains this exception - its configured IServiceProvider, registration
+ // factory closures, scope-held shared values, and trace buffer, none of which this comparison
+ // needs. A bare bool couldn't distinguish "some context produced this" from "this context's active
+ // nested resolution produced it" either (a registration factory can capture and invoke an entirely
+ // different Composer and let that composer's own pipeline-diagnosed exception escape, or rethrow
+ // one it captured earlier) - InvokeFactory's catch guard (docs/adr/0010) needs identity, just not
+ // by holding the whole context to get it.
+ internal object? DiagnosingContextIdentity { get; private init; }
+
+ // The only construction path that sets DiagnosingContextIdentity - used exclusively by
+ // CompositionContext.BuildException (passing its own Identity token), never by a factory/rule that
+ // constructs a CompositionException itself.
+ internal static CompositionException CreatePipelineDiagnosed(CompositionDiagnostic diagnostic, object diagnosingContextIdentity) =>
+ new(diagnostic) { DiagnosingContextIdentity = diagnosingContextIdentity };
+
+ internal static CompositionException CreatePipelineDiagnosed(CompositionDiagnostic diagnostic, Exception innerException, object diagnosingContextIdentity) =>
+ new(diagnostic, innerException) { DiagnosingContextIdentity = diagnosingContextIdentity };
+
// The base(...) initializer runs before this constructor's own body, so a guard clause in the
// body would already be too late - diagnostic.Message is dereferenced in the initializer itself.
// Routing the null check through this helper is what lets a null argument surface as
@@ -89,12 +127,9 @@ private static Exception RequireInnerException(Exception innerException)
return innerException;
}
- // The only construction path that sets DiagnosingContextIdentity - used exclusively by
- // CompositionContext.BuildException (passing its own Identity token), never by a factory/rule that
- // constructs a CompositionException itself.
- internal static CompositionException CreatePipelineDiagnosed(CompositionDiagnostic diagnostic, object diagnosingContextIdentity) =>
- new(diagnostic) { DiagnosingContextIdentity = diagnosingContextIdentity };
-
- internal static CompositionException CreatePipelineDiagnosed(CompositionDiagnostic diagnostic, Exception innerException, object diagnosingContextIdentity) =>
- new(diagnostic, innerException) { DiagnosingContextIdentity = diagnosingContextIdentity };
+ private CompositionException(string message, CompositionDiagnostic? diagnostic, Exception innerException)
+ : base(message, innerException)
+ {
+ Diagnostic = diagnostic;
+ }
}
diff --git a/test/Compono.Tests/CompositionExceptionTests.cs b/test/Compono.Tests/CompositionExceptionTests.cs
index 90db5f2..be379cb 100644
--- a/test/Compono.Tests/CompositionExceptionTests.cs
+++ b/test/Compono.Tests/CompositionExceptionTests.cs
@@ -16,6 +16,47 @@ public void Constructor_ThrowsArgumentNullException_WhenDiagnosticIsNull()
act.Should().Throw();
}
+ [Fact]
+ public void WithSeedInMessage_RewritesMessage_ButPreservesDiagnosticAndSetsInnerException()
+ {
+ // Internal seam for Compono.XunitV3 (PR #26 review; ADR-0022 Amendment 5) - a pipeline-thrown
+ // CompositionException's own Message never carried the seed on its own (only Diagnostic did,
+ // via its own ToString()), so Compono.XunitV3's GetData rewrites it before propagating.
+ var diagnostic = new CompositionDiagnostic
+ {
+ RootType = typeof(CompositionExceptionTests),
+ FailedType = typeof(CompositionExceptionTests),
+ Path = "unrelated-path",
+ Trace = [],
+ Seed = 0,
+ Message = "original pipeline failure message",
+ };
+ var original = CompositionException.CreatePipelineDiagnosed(diagnostic, diagnosingContextIdentity: new object());
+
+ var wrapped = CompositionException.WithSeedInMessage(original, seed: 492173);
+
+ wrapped.Message.Should().Be("original pipeline failure message\n\nSeed: 492173");
+ wrapped.Diagnostic.Should().BeSameAs(diagnostic);
+ wrapped.Diagnostic!.Message.Should().Be("original pipeline failure message", "Diagnostic's own Message is left untouched - only the outer exception's Message is rewritten");
+ wrapped.InnerException.Should().BeSameAs(original);
+ }
+
+ [Fact]
+ public void WithSeedInMessage_RewritesMessage_AndLeavesDiagnosticNull_ForAPlainMessageOriginal()
+ {
+ // PR #26 review, third round - a generated HashSet/Dictionary collection plan's
+ // unique-value-exhaustion path (CollectionPlan.scriban) throws exactly this shape: a plain
+ // CompositionException(string), no Diagnostic at all. That failure deserves the same
+ // pasteable seed as a diagnosed one, so WithSeedInMessage must handle it too, not throw.
+ var original = new CompositionException("a plain, non-pipeline-diagnosed message");
+
+ var wrapped = CompositionException.WithSeedInMessage(original, seed: 1);
+
+ wrapped.Message.Should().Be("a plain, non-pipeline-diagnosed message\n\nSeed: 1");
+ wrapped.Diagnostic.Should().BeNull();
+ wrapped.InnerException.Should().BeSameAs(original);
+ }
+
[Fact]
public void DoesNotDeclareAnyMemberOfTypeCompositionContext()
{
diff --git a/test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj b/test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj
new file mode 100644
index 0000000..c1471a0
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj
@@ -0,0 +1,74 @@
+
+
+
+ enable
+ enable
+ Exe
+ Compono.XunitV3.SampleTests
+ false
+ true
+
+ true
+ true
+
+ $(MSBuildThisFileDirectory)../../.local-nuget-feed
+
+ manual
+ $(MSBuildThisFileDirectory)obj/.nuget-packages/$(Compono_LocalPackagesId)/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Compono.XunitV3.SampleTests/Domain.cs b/test/Compono.XunitV3.SampleTests/Domain.cs
new file mode 100644
index 0000000..bae4df1
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/Domain.cs
@@ -0,0 +1,42 @@
+namespace Compono.XunitV3.SampleTests;
+
+// Reached only through SharedTests.SharedRepositoryIsReusedByTheService's own [Compose]-attributed
+// theory parameters - no [Composable], no Create()/CreateMany(), no direct CompositionRow call
+// site anywhere else in this project. Proves Compono.Generators.ComposeMethodDiscovery (Phase 1)
+// generates a real plan through the packaged Compono.XunitV3 -> Compono dependency, not just an
+// isolated Compono.Generators.Tests snapshot test.
+public sealed class Repository;
+
+public sealed class OrderService
+{
+ public OrderService(Repository repository)
+ {
+ Repository = repository;
+ }
+
+ public Repository Repository { get; }
+}
+
+public sealed record CreateOrder(string CustomerName, int Quantity);
+
+// No provider, registration, [Composable], or generated plan can ever satisfy this - reserved for
+// FailingCompositionTests, which needs a genuine (pipeline-propagated) composition failure rather
+// than one of Compono.XunitV3's own pre-composition validation failures (PR #26 review).
+//
+// Deliberately never used as a [Compose]-attributed parameter's own type directly - an
+// interface/abstract/delegate root always reports CMP0003 at compile time (a documented, deliberate
+// PLAN-0004 Open Item: ComposedTypeAnalyzer's root-level check is stricter than its nested-member
+// check), so an abstract root here would be a compile error, not the runtime composition failure this
+// fixture needs. GatewayConsumer below wraps it as a nested constructor parameter instead, which uses
+// the more lenient member-level check and is left as a runtime Resolve call.
+public interface IUnregisteredGateway;
+
+public sealed class GatewayConsumer
+{
+ public GatewayConsumer(IUnregisteredGateway gateway)
+ {
+ Gateway = gateway;
+ }
+
+ public IUnregisteredGateway Gateway { get; }
+}
diff --git a/test/Compono.XunitV3.SampleTests/FailingCompositionTests.cs b/test/Compono.XunitV3.SampleTests/FailingCompositionTests.cs
new file mode 100644
index 0000000..3b3edca
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/FailingCompositionTests.cs
@@ -0,0 +1,21 @@
+namespace Compono.XunitV3.SampleTests;
+
+// Deliberately fails, on every run, via a genuine (pipeline-propagated) composition failure - not one
+// of Compono.XunitV3's own pre-composition validation failures (a negative seed, a signature error, an
+// inline-value mismatch), which don't exercise real composition at all (PR #26 review). Uses an
+// explicit seed so Compono.XunitV3.Tests' RealRunnerTests can assert on a deterministic value: it
+// shells out `dotnet test` against this project and asserts the captured output contains this exact
+// seed, proving the milestone's "a composition failure's message contains a seed that reproduces the
+// same failure" promise reaches a real xUnit v3 runner's actual output, not just an in-process GetData
+// call.
+public sealed class FailingCompositionTests
+{
+ public const int Seed = 24601;
+
+ [Theory]
+ [Compose(Seed = Seed)]
+ public void DeliberatelyFailingComposition_NoProviderCanSatisfyTheNestedInterfaceDependency(GatewayConsumer consumer)
+ {
+ consumer.Should().BeNull("GetData throws before this body ever runs - this line never executes");
+ }
+}
diff --git a/test/Compono.XunitV3.SampleTests/InlineAndComposedTests.cs b/test/Compono.XunitV3.SampleTests/InlineAndComposedTests.cs
new file mode 100644
index 0000000..690f1d7
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/InlineAndComposedTests.cs
@@ -0,0 +1,37 @@
+namespace Compono.XunitV3.SampleTests;
+
+public sealed class InlineAndComposedTests
+{
+ [Theory]
+ [Compose(42, "widget")]
+ public void InlineValuesAreUsedDirectly(int quantity, string productName)
+ {
+ quantity.Should().Be(42);
+ productName.Should().Be("widget");
+ }
+
+ [Theory]
+ [Compose]
+ public void ComposedValuesAreProducedForEveryParameter(int quantity, string productName)
+ {
+ ((object)quantity).Should().BeOfType();
+ productName.Should().NotBeNull();
+ }
+
+ [Theory]
+ [Compose(42)]
+ public void MixesInlineAndComposedValues(int quantity, string productName)
+ {
+ quantity.Should().Be(42);
+ productName.Should().NotBeNull();
+ }
+
+ [Theory]
+ [Compose]
+ public async Task ComposesForAnAsyncTheory(string value)
+ {
+ await Task.Yield();
+
+ value.Should().NotBeNull();
+ }
+}
diff --git a/test/Compono.XunitV3.SampleTests/ProfileTests.cs b/test/Compono.XunitV3.SampleTests/ProfileTests.cs
new file mode 100644
index 0000000..729323d
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/ProfileTests.cs
@@ -0,0 +1,22 @@
+namespace Compono.XunitV3.SampleTests;
+
+public sealed record NotificationSettings(string SenderAddress);
+
+// Reached only through ProfileTests.ComposesTheProfileConfiguredValue's own [Compose]
+// theory parameter - proves ComposeAttribute actually applies TProfile.Configure through the
+// real packaged pipeline, not just Compono.XunitV3.Tests' in-process GetComposer()/CreateRow checks.
+public sealed class ApplicationTestProfile : ICompositionProfile
+{
+ public void Configure(CompositionBuilder builder) =>
+ builder.Register(() => new NotificationSettings("sample-test-sender@example.com"));
+}
+
+public sealed class ProfileTests
+{
+ [Theory]
+ [Compose]
+ public void ComposesTheProfileConfiguredValue(NotificationSettings settings)
+ {
+ settings.SenderAddress.Should().Be("sample-test-sender@example.com");
+ }
+}
diff --git a/test/Compono.XunitV3.SampleTests/SharedTests.cs b/test/Compono.XunitV3.SampleTests/SharedTests.cs
new file mode 100644
index 0000000..9cbf9a1
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/SharedTests.cs
@@ -0,0 +1,12 @@
+namespace Compono.XunitV3.SampleTests;
+
+public sealed class SharedTests
+{
+ [Theory]
+ [Compose]
+ public void SharedRepositoryIsReusedByTheService([Shared] Repository repository, OrderService service, CreateOrder command)
+ {
+ service.Repository.Should().BeSameAs(repository);
+ command.Should().NotBeNull();
+ }
+}
diff --git a/test/Compono.XunitV3.SampleTests/nuget.config b/test/Compono.XunitV3.SampleTests/nuget.config
new file mode 100644
index 0000000..5d060c7
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/nuget.config
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
diff --git a/test/Compono.XunitV3.SampleTests/pack-to-local-feed.sh b/test/Compono.XunitV3.SampleTests/pack-to-local-feed.sh
new file mode 100755
index 0000000..3bf6f8b
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/pack-to-local-feed.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Packs Compono and Compono.XunitV3 into the local NuGet feed this project restores against
+# (test/Compono.XunitV3.SampleTests/nuget.config), serialized behind a cross-process lock, and clears
+# this restore's own isolated packages path before every pack.
+#
+# Why the lock: this script is invoked from a PackToLocalFeed MSBuild target
+# (BeforeTargets="Restore") on every restore of this project. CI (and RealRunnerTests, which shells
+# out `dotnet test` against this project) runs Compono.XunitV3.Tests for multiple TFMs concurrently,
+# so two independent nested `dotnet test` processes can each trigger this script at the same moment,
+# both packing the same src/Compono and src/Compono.Generators projects into the same
+# .local-nuget-feed/ directory - a real race (reproduced locally: "The process cannot access the
+# file '.../Compono.1.0.0.nupkg' because it is being used by another process", and in CI: "Could not
+# find a part of the path '.../Compono.Generators/bin/Debug/netstandard2.0'"). `mkdir` is atomic even
+# across processes and platforms, so it works as a portable mutex without a custom MSBuild task.
+#
+# Why $restore_packages_path is cleared here (PR #26 review, second round): NuGet treats a package
+# id+version already present in a packages folder as immutable and never re-extracts it, so this
+# script repacking different content under the same fixed "1.0.0" version would otherwise let a stale
+# entry silently satisfy a later restore. Two earlier fixes were tried and reverted first (see
+# PLAN-0004 Phase 3 Notes for the full account): clearing NuGet's *shared global* packages folder here
+# raced against a sibling process's own restore still reading from it after this script's lock
+# released ("Could not find a part of the path '.../packages/compono/1.0.0'", reproduced locally and
+# caught in CI); a per-evaluation-random package version (via MSBuild's $([System.Guid]::NewGuid()))
+# turned out unstable, because NuGet's restore-graph evaluation pass and the actual build evaluation
+# pass each re-evaluate that property function independently, producing two different GUIDs for the
+# same nominal build (a real NU1102 version mismatch, reproduced locally). $restore_packages_path
+# (scoped per $(TargetFramework) by the caller) has neither problem: it's private to this one
+# project+TFM's own restore, so clearing it can never affect a concurrent sibling process running a
+# different TFM, and there is nothing left to make "the same value across evaluation passes" in the
+# first place - it's a plain path, not a value that needs to agree with anything else.
+set -euo pipefail
+
+compono_csproj="$1"
+xunitv3_csproj="$2"
+feed_dir="$3"
+configuration="$4"
+restore_packages_path="$5"
+
+lock_dir="$feed_dir/.pack.lock"
+
+# Bounded wait, not an unconditional retry loop (PR #26 review, sixth round) - if a prior holder was
+# killed before its own EXIT trap could run (SIGKILL, a canceled build, a machine restart), the lock
+# directory is never cleaned up, and every subsequent restore of this project would otherwise wait on
+# it forever. A real pack of both projects normally takes well under a minute, so 120 attempts (~2
+# minutes) is generous headroom for a genuinely concurrent sibling while still failing fast, with
+# actionable remediation, on a truly abandoned lock rather than hanging indefinitely.
+max_wait_attempts=120
+attempt=0
+
+until mkdir "$lock_dir" 2>/dev/null; do
+ attempt=$((attempt + 1))
+ if [ "$attempt" -ge "$max_wait_attempts" ]; then
+ echo "pack-to-local-feed.sh: timed out after ${max_wait_attempts}s waiting for the lock at '$lock_dir'." >&2
+ echo "This usually means a previous run was killed before it could release the lock. If no other" >&2
+ echo "'dotnet pack'/'dotnet test' against this project is currently running, remove it manually and" >&2
+ echo "retry: rm -rf '$lock_dir'" >&2
+ exit 1
+ fi
+ sleep 1
+done
+trap 'rmdir "$lock_dir"' EXIT
+
+rm -rf "$restore_packages_path"
+
+dotnet pack "$compono_csproj" -c "$configuration" -o "$feed_dir" -p:Version=1.0.0 --nologo
+dotnet pack "$xunitv3_csproj" -c "$configuration" -o "$feed_dir" -p:Version=1.0.0 --nologo
diff --git a/test/Compono.XunitV3.SampleTests/xunit.runner.json b/test/Compono.XunitV3.SampleTests/xunit.runner.json
new file mode 100644
index 0000000..86c7ea0
--- /dev/null
+++ b/test/Compono.XunitV3.SampleTests/xunit.runner.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
+}
diff --git a/test/Compono.XunitV3.Tests/ComposeAttributeConcurrencyTests.cs b/test/Compono.XunitV3.Tests/ComposeAttributeConcurrencyTests.cs
new file mode 100644
index 0000000..58dd7e5
--- /dev/null
+++ b/test/Compono.XunitV3.Tests/ComposeAttributeConcurrencyTests.cs
@@ -0,0 +1,32 @@
+using System.Collections.Concurrent;
+using Compono.XunitV3.Tests.Fixtures;
+using Xunit.Sdk;
+
+namespace Compono.XunitV3.Tests;
+
+public sealed class ComposeAttributeConcurrencyTests
+{
+ [Fact]
+ public async Task GetData_ProducesNoExceptionsOrDataRaces_WhenCalledConcurrently_OnOneSharedAttributeInstance()
+ {
+ // GetData runs fully synchronously and returns an already-completed ValueTask, so awaiting
+ // Task.WhenAll over a lazily-Select-projected sequence of .AsTask() calls does NOT exercise
+ // overlapping execution at all - WhenAll enumerates that deferred sequence one element at a
+ // time, and since each GetData call has already finished by the time .AsTask() returns, call
+ // N+1 is never even created until call N is done (PR #26 review, third round).
+ // Parallel.ForEachAsync genuinely dispatches all 200 calls across the thread pool
+ // concurrently, so this actually exercises concurrent first-touch access to the shared
+ // Lazy/binding-plan initialization ADR-0022's Caching section describes.
+ var attribute = new ComposeAttribute();
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.Simple))!;
+ var lengths = new ConcurrentBag();
+
+ await Parallel.ForEachAsync(Enumerable.Range(0, 200), async (_, _) =>
+ {
+ var rows = await attribute.GetData(method, new DisposalTracker());
+ lengths.Add(rows.Single().GetData().Length);
+ });
+
+ lengths.Should().HaveCount(200).And.OnlyContain(length => length == 2);
+ }
+}
diff --git a/test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs b/test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs
index 23c5d3b..24c37c9 100644
--- a/test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs
+++ b/test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs
@@ -20,6 +20,59 @@ public static void WithNullableParameters(string? nullableReference, string notN
{
}
+ public static void WithNullableReferenceParameter(string? value)
+ {
+ }
+
+ public static void WithNonNullableReferenceParameter(string value)
+ {
+ }
+
+ public static void WithNullableValueParameter(int? value)
+ {
+ }
+
+ public static void WithNonNullableValueParameter(int value)
+ {
+ }
+
+ // Deliberately single-parameter - a second same-typed parameter would read this shared value
+ // back from scope too (Phase 0's stage-2 read gate applies to every same-typed request in the
+ // row, not just ones marked [Shared]) and re-validate it against that parameter's own
+ // nullability, which isn't what these fixtures exist to test.
+ public static void WithSharedNullableReferenceParameter([Shared] string? value)
+ {
+ }
+
+ public static void WithSharedNonNullableReferenceParameter([Shared] string value)
+ {
+ }
+
+ public static void WithSharedNullableValueParameter([Shared] int? value)
+ {
+ }
+
+ public static void WithSharedNonNullableValueParameter([Shared] int value)
+ {
+ }
+
+ // No provider and no generated plan can ever satisfy this interface (this test project doesn't
+ // reference Compono.Generators as an analyzer, and nothing registers it) - composing it always
+ // fails, deterministically, which is exactly what the seed-message-content proof needs.
+ public static void WithUnregisteredInterfaceParameter(IUnregisteredDependency value)
+ {
+ }
+
+ // Reaches CollectionExhaustionPlan (registered via CollectionPlanCache>.Instance,
+ // since this test project doesn't reference Compono.Generators as an analyzer) - a plain-message
+ // CompositionException, no Diagnostic at all, exactly matching what the real generated
+ // HashSet/Dictionary collection plan throws on unique-value exhaustion
+ // (CollectionPlan.scriban). Proves GetData appends the seed to this shape too, not only a
+ // pipeline-diagnosed one (PR #26 review, third round).
+ public static void WithExhaustedHashSetParameter(HashSet values)
+ {
+ }
+
public static void WithDisposableParameter(DisposableValue disposable)
{
}
@@ -66,6 +119,30 @@ public sealed class TestProfile : ICompositionProfile
public void Configure(CompositionBuilder builder) => builder.Register(() => "from-profile");
}
+ // Mirrors CollectionPlan.scriban's own HashSet shape exactly (same UniqueValueResolver call,
+ // same plain-message CompositionException on exhaustion) rather than just throwing directly,
+ // since this test project doesn't reference Compono.Generators as an analyzer and can't get the
+ // real generated plan (testing.md's hand-fake convention). Registered by the test itself (not a
+ // static constructor here - typeof(...)/GetMethod(...) don't trigger a type's static constructor,
+ // only an actual member access does), matching Compono.Tests' CollectionPlanCacheDispatchTests
+ // register/try-finally-unregister pattern.
+ public sealed class CollectionExhaustionPlan : ICompositionPlan>
+ {
+ public HashSet Compose(ICompositionContext context)
+ {
+ var size = context.ResolveCollectionSize();
+ var result = new HashSet(size);
+
+ for (var i = 0; i < size; i++)
+ {
+ if (!UniqueValueResolver.TryResolve(context, CompositionRequestKind.CollectionElement, i, Nullability.NotNullable, result, out _))
+ throw new CompositionException($"Could not generate {size} unique values of type 'bool' for 'HashSet' after {UniqueValueResolver.MaxAttempts} attempts per element - the element type's value space is likely too small for the requested collection size.");
+ }
+
+ return result;
+ }
+ }
+
// Composed via a registration, not a generated plan - this test project doesn't reference
// Compono.Generators as an analyzer (testing.md), so a registration is the only way to get a
// real (non-fake) composed value for a custom type here.
@@ -83,3 +160,5 @@ public sealed class DisposableValue : IDisposable
public void Dispose() => DisposeCount++;
}
+
+internal interface IUnregisteredDependency;
diff --git a/test/Compono.XunitV3.Tests/InlineNullHandlingTests.cs b/test/Compono.XunitV3.Tests/InlineNullHandlingTests.cs
new file mode 100644
index 0000000..84d8753
--- /dev/null
+++ b/test/Compono.XunitV3.Tests/InlineNullHandlingTests.cs
@@ -0,0 +1,117 @@
+using Compono.XunitV3.Tests.Fixtures;
+using Xunit.Sdk;
+
+namespace Compono.XunitV3.Tests;
+
+// The four null/non-null-parameter combinations Phase 3's plan requires, each covered for both an
+// ordinary parameter and an inline-[Shared] parameter - proving rejection happens before
+// ShareExplicit's invoker is ever reached (a rejected inline value fails GetData's own
+// pre-composition validation loop, which runs before either binding loop touches a parameter).
+public sealed class InlineNullHandlingTests
+{
+ [Fact]
+ public async Task GetData_AcceptsANullInlineValue_ForANullableReferenceParameter()
+ {
+ var attribute = new ComposeAttribute((string?)null);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNullableReferenceParameter))!;
+
+ var rows = await attribute.GetData(method, new DisposalTracker());
+
+ rows.Single().GetData()[0].Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetData_AcceptsANullInlineValue_ForANullableValueParameter()
+ {
+ var attribute = new ComposeAttribute((int?)null);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNullableValueParameter))!;
+
+ var rows = await attribute.GetData(method, new DisposalTracker());
+
+ rows.Single().GetData()[0].Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetData_RejectsANullInlineValue_ForANonNullableReferenceParameter()
+ {
+ var attribute = new ComposeAttribute((object?)null);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableReferenceParameter))!;
+
+ var act = () => attribute.GetData(method, new DisposalTracker()).AsTask();
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*value*");
+ }
+
+ [Fact]
+ public async Task GetData_RejectsANullInlineValue_ForANonNullableValueParameter()
+ {
+ var attribute = new ComposeAttribute((object?)null);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNonNullableValueParameter))!;
+
+ var act = () => attribute.GetData(method, new DisposalTracker()).AsTask();
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*value*");
+ }
+
+ [Fact]
+ public async Task GetData_AcceptsANullInlineValue_ForASharedNullableReferenceParameter()
+ {
+ var attribute = new ComposeAttribute((string?)null);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithSharedNullableReferenceParameter))!;
+
+ var rows = await attribute.GetData(method, new DisposalTracker());
+
+ rows.Single().GetData()[0].Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetData_AcceptsANullInlineValue_ForASharedNullableValueParameter()
+ {
+ var attribute = new ComposeAttribute((int?)null);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithSharedNullableValueParameter))!;
+
+ var rows = await attribute.GetData(method, new DisposalTracker());
+
+ rows.Single().GetData()[0].Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetData_RejectsANullInlineValue_ForASharedNonNullableReferenceParameter()
+ {
+ var attribute = new ComposeAttribute((object?)null);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithSharedNonNullableReferenceParameter))!;
+
+ var act = () => attribute.GetData(method, new DisposalTracker()).AsTask();
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*value*");
+ }
+
+ [Fact]
+ public async Task GetData_RejectsANullInlineValue_ForASharedNonNullableValueParameter()
+ {
+ var attribute = new ComposeAttribute((object?)null);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithSharedNonNullableValueParameter))!;
+
+ var act = () => attribute.GetData(method, new DisposalTracker()).AsTask();
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*value*");
+ }
+
+ [Fact]
+ public async Task GetData_AcceptsANonNullInlineValue_ForANullableValueParameter()
+ {
+ // The regression test for the boxed-T-vs-boxed-Nullable CLR bug: a non-null int arrives
+ // boxed as a boxed int, not a boxed int? - without the Nullable.GetUnderlyingType unwrap this
+ // is wrongly rejected against the raw int? parameter type.
+ var attribute = new ComposeAttribute(42);
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithNullableValueParameter))!;
+
+ var rows = await attribute.GetData(method, new DisposalTracker());
+
+ rows.Single().GetData()[0].Should().Be(42);
+ }
+}
diff --git a/test/Compono.XunitV3.Tests/PublicApiSurfaceTests.cs b/test/Compono.XunitV3.Tests/PublicApiSurfaceTests.cs
new file mode 100644
index 0000000..5b701fb
--- /dev/null
+++ b/test/Compono.XunitV3.Tests/PublicApiSurfaceTests.cs
@@ -0,0 +1,29 @@
+namespace Compono.XunitV3.Tests;
+
+// Cheap insurance against accidental public-API drift (Phase 3's plan task) - locks the exact set of
+// public types Compono.XunitV3 exposes, matching this milestone's "keep public APIs minimal"
+// constraint (docs/public-api.md). A hand-rolled exact-set assertion, not a Verify snapshot: the
+// expected shape is a fixed, short list, so a snapshot file would add ceremony without adding
+// coverage testing.md doesn't already ask for.
+public sealed class PublicApiSurfaceTests
+{
+ [Fact]
+ public void Assembly_ExposesExactlyTheDocumentedPublicTypes()
+ {
+ // IsPublic alone misses an accidentally-added nested public type (PR #26 review, fourth
+ // round): a nested type is never IsPublic (only a top-level type can be), it's IsNestedPublic
+ // instead - reflection's own naming split, not a synonym. Checking only IsPublic would let an
+ // unapproved `public class Foo { public class Bar { } }` addition slip through this exact-set
+ // assertion undetected.
+ var publicTypeNames = typeof(ComposeAttribute).Assembly.GetTypes()
+ .Where(static type => type.IsPublic || type.IsNestedPublic)
+ .Select(static type => type.FullName);
+
+ publicTypeNames.Should().BeEquivalentTo(
+ [
+ "Compono.XunitV3.ComposeAttribute",
+ "Compono.XunitV3.ComposeAttribute`1",
+ "Compono.XunitV3.SharedAttribute",
+ ]);
+ }
+}
diff --git a/test/Compono.XunitV3.Tests/SeedReportingTests.cs b/test/Compono.XunitV3.Tests/SeedReportingTests.cs
new file mode 100644
index 0000000..92207da
--- /dev/null
+++ b/test/Compono.XunitV3.Tests/SeedReportingTests.cs
@@ -0,0 +1,96 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Compono.XunitV3.Tests.Fixtures;
+using Xunit.Sdk;
+
+namespace Compono.XunitV3.Tests;
+
+// Proves the pasteable-seed promise itself, not just a "Seed:" label's presence, for a genuine
+// (pipeline-propagated) composition failure - as opposed to one of Compono.XunitV3's own
+// pre-composition exceptions (negative seed, signature errors, inline validation), which already
+// append "Seed: " straight into Message via AppendSeed. A pipeline-thrown CompositionException's
+// own Message never carried the seed on its own (only exception.Diagnostic did, via its own
+// ToString()) - a real test runner's failure display shows Message, not Diagnostic.ToString(), so
+// GetData now rewrites it via CompositionException.WithSeedInMessage (PR #26 review; ADR-0022
+// Amendment 5) before it propagates. Diagnostic itself (and its own correct Seed/ToString() rendering)
+// is untouched by this - these tests check both.
+public sealed partial class SeedReportingTests
+{
+ [Fact]
+ public async Task GetData_FailingComposition_MessageContainsTheAutoGeneratedRowSeed_AndReproducesTheSameFailure_WhenPastedBack()
+ {
+ var attribute = new ComposeAttribute();
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithUnregisteredInterfaceParameter))!;
+
+ var firstAct = () => attribute.GetData(method, new DisposalTracker()).AsTask();
+ var firstException = (await firstAct.Should().ThrowAsync()).Which;
+
+ var match = SeedLine().Match(firstException.Message);
+ match.Success.Should().BeTrue("GetData rewrites a propagating composition failure's Message to include a 'Seed: ' line");
+ var pastedSeed = int.Parse(match.Groups["seed"].Value, CultureInfo.InvariantCulture);
+
+ var pastedAttribute = new ComposeAttribute { Seed = pastedSeed };
+ var secondAct = () => pastedAttribute.GetData(method, new DisposalTracker()).AsTask();
+ var secondException = (await secondAct.Should().ThrowAsync()).Which;
+
+ secondException.Message.Should().Contain($"Seed: {pastedSeed}");
+ }
+
+ [Fact]
+ public async Task GetData_FailingComposition_MessageContainsTheExplicitRowSeed()
+ {
+ const int seed = 7654321;
+ var attribute = new ComposeAttribute { Seed = seed };
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithUnregisteredInterfaceParameter))!;
+
+ var act = () => attribute.GetData(method, new DisposalTracker()).AsTask();
+ var exception = (await act.Should().ThrowAsync()).Which;
+
+ exception.Message.Should().Contain(seed.ToString(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public async Task GetData_FailingComposition_DiagnosticIsPreservedUnchanged_AlongsideTheRewrittenMessage()
+ {
+ const int seed = 987654;
+ var attribute = new ComposeAttribute { Seed = seed };
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithUnregisteredInterfaceParameter))!;
+
+ var act = () => attribute.GetData(method, new DisposalTracker()).AsTask();
+ var exception = (await act.Should().ThrowAsync()).Which;
+
+ exception.Diagnostic!.Seed.Should().Be((ulong)seed);
+ exception.Diagnostic.Message.Should().NotContain("Seed:", "the wrapped Message carries the seed - Diagnostic's own Message field is left as the pipeline's original plain text");
+ exception.InnerException.Should().NotBeNull("the original pipeline exception is preserved, never discarded");
+ }
+
+ [Fact]
+ public async Task GetData_FailingComposition_MessageContainsTheSeed_ForAPlainMessageExceptionWithNoDiagnostic()
+ {
+ // CollectionPlan.scriban's generated HashSet/Dictionary unique-value-exhaustion path throws
+ // a plain CompositionException(string) - no Diagnostic at all - so this proves the earlier
+ // catch-filter gap (PR #26 review, third round) is actually closed: every CompositionException
+ // gets the seed appended, not only a pipeline-diagnosed one.
+ const int seed = 246810;
+ var attribute = new ComposeAttribute { Seed = seed };
+ var method = typeof(SampleTestMethods).GetMethod(nameof(SampleTestMethods.WithExhaustedHashSetParameter))!;
+
+ CollectionPlanCache>.Instance = new SampleTestMethods.CollectionExhaustionPlan();
+
+ try
+ {
+ var act = () => attribute.GetData(method, new DisposalTracker()).AsTask();
+ var exception = (await act.Should().ThrowAsync()).Which;
+
+ exception.Diagnostic.Should().BeNull();
+ exception.Message.Should().Contain($"Seed: {seed}");
+ }
+ finally
+ {
+ CollectionPlanCache>.Instance = null;
+ }
+ }
+
+ [GeneratedRegex(@"Seed: (?\d+)")]
+ private static partial Regex SeedLine();
+}