diff --git a/.agents/skills/engineering-workflow/SKILL.md b/.agents/skills/engineering-workflow/SKILL.md index f6cc7358..782c2ab4 100644 --- a/.agents/skills/engineering-workflow/SKILL.md +++ b/.agents/skills/engineering-workflow/SKILL.md @@ -106,6 +106,7 @@ otherwise favors. | When you're about to... | Read | |---|---| +| Decide whether a proposed capability, feature, integration, or new package belongs in Compono at all, before any design work starts | [`docs/architecture/capability-admission.md`](../../../docs/architecture/capability-admission.md) (standalone; read it directly, not via `references/`) | | Decide where an architecture/feature decision belongs, run a design dive (light or deep) before writing code, write/reference an ADR (`docs/adr/`), or write/track a plan (`docs/plans/`) | `references/design-decisions.md` | | Write or review any C# (naming, nullable, async, DI, error handling, file layout) | `references/coding-standards.md` | | Add or change tests | `references/testing.md` | diff --git a/.agents/skills/engineering-workflow/references/design-decisions.md b/.agents/skills/engineering-workflow/references/design-decisions.md index 305a7e3b..92d70294 100644 --- a/.agents/skills/engineering-workflow/references/design-decisions.md +++ b/.agents/skills/engineering-workflow/references/design-decisions.md @@ -11,6 +11,17 @@ already decided" is more useful than one that re-derives a solved problem from first principles, and it's a fast check relative to the cost of designing around a wrong assumption. +If the request is a genuinely new capability, a material expansion of an +existing package's public surface, or a new extension/integration +package — not a bug fix or straightforward implementation against an +already-`Accepted` ADR — run it through +[`docs/architecture/capability-admission.md`](../../../../docs/architecture/capability-admission.md) +**before** deciding light vs. deep dive below. That page is the standalone, +current process for whether something belongs in Compono at all; a +candidate that doesn't clear it doesn't get an ADR of its own, light or +deep. `docs/adr/0029-...` and `docs/adr/0039-...` are that page's +underlying decisions, not a substitute for reading it directly. + ## Where decisions live Four places, each with a different job — don't blur them together: diff --git a/.agents/skills/engineering-workflow/tasks/design.md b/.agents/skills/engineering-workflow/tasks/design.md index 0d2857c6..a4825b6d 100644 --- a/.agents/skills/engineering-workflow/tasks/design.md +++ b/.agents/skills/engineering-workflow/tasks/design.md @@ -40,6 +40,14 @@ alternatives are even legal: the docs, here's where" (`design-decisions.md`'s opening rule). A design session grounded in what's already decided beats one that re-derives a solved problem. + + If the request is a genuinely new capability, a material expansion of + an existing package's public surface, or a new extension/integration + package, run it through + [`docs/architecture/capability-admission.md`](../../../../docs/architecture/capability-admission.md) + first — a candidate that doesn't clear that process doesn't get an ADR + at all, and this task shouldn't reconstruct that admission reasoning + from `docs/adr/0029-...`/`docs/adr/0039-...` from scratch each time. 2. **Decide light vs. deep**, per `design-decisions.md`: - **Light** — problem and solution shape are already clear (adding a provider following a pattern another provider already uses, adopting diff --git a/.github/scripts/inspect-packed-nupkgs.sh b/.github/scripts/inspect-packed-nupkgs.sh index 8a0b0fdf..cd91a40e 100755 --- a/.github/scripts/inspect-packed-nupkgs.sh +++ b/.github/scripts/inspect-packed-nupkgs.sh @@ -245,7 +245,7 @@ main() { } local pkg nupkg extract_dir extra_paths nuspec - for pkg in Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit; do + for pkg in Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit Compono.Options; do nupkg=$(find "$pack_output" -maxdepth 1 -iname "${pkg}.[0-9]*.nupkg" | head -1) if [ -z "$nupkg" ]; then echo "FAIL: no .nupkg found for $pkg in $pack_output" >&2 @@ -339,6 +339,14 @@ main() { assert_exact_pin_dependency "$nuspec" "$pkg" "Compono" assert_dependency_range "$nuspec" "$pkg" "NUnit" "$authoritative_json" ;; + Compono.Options) + assert_manifest_field "$nuspec" "$pkg" "title" "Compono — Configuration/Options Testing Support" + assert_exact_pin_dependency "$nuspec" "$pkg" "Compono" + # Per-TFM range, same shape as Compono.Logging's Microsoft.Extensions.Logging.Abstractions + # dependency above (net11.0 carries no explicit dependency entry - satisfied by that TFM's + # own shared framework, confirmed against a real local pack). + assert_dependency_range_per_tfm "$nuspec" "$pkg" "Microsoft.Extensions.Options" "$packages_props" + ;; esac done diff --git a/.github/workflows/aot-validation.yaml b/.github/workflows/aot-validation.yaml index 3d283db5..0c646910 100644 --- a/.github/workflows/aot-validation.yaml +++ b/.github/workflows/aot-validation.yaml @@ -1,6 +1,6 @@ name: AOT Validation -# ADR-0041 Amendment 7: permanent, CI-blocking Native AOT smoke gate for the eight existing +# ADR-0041 Amendment 7: permanent, CI-blocking Native AOT smoke gate for the nine existing # test/*.AotSmokeTest projects, replacing the previous manual-only "run it by hand before release" # verification. # @@ -8,7 +8,7 @@ name: AOT Validation # skipped by trigger-level path filtering leaves its required status check `Pending` rather than # reporting success, under GitHub's required-check semantics - that would block a PR indefinitely # instead of passing it on an AOT-irrelevant change. Selectivity happens *inside* the workflow -# instead: the `changes` job below computes which of the eight legs are actually applicable from the +# instead: the `changes` job below computes which of the nine legs are actually applicable from the # PR's changed files (a small repository-owned `git diff` script, not a third-party changed-files # action), each leg's own publish-and-run job runs behind an `if:` reading that output (an # inapplicable leg reports an ordinary skipped conclusion, never a missing status), and `aot-gate` - @@ -56,14 +56,14 @@ jobs: echo "Changed files:" echo "$changed" - all_legs='["Compono","Compono.Http","Compono.Logging","Compono.MSTest","Compono.NUnit","Compono.TestDoubles","Compono.TUnit","Compono.XunitV3"]' + all_legs='["Compono","Compono.Http","Compono.Logging","Compono.MSTest","Compono.NUnit","Compono.Options","Compono.TestDoubles","Compono.TUnit","Compono.XunitV3"]' # A change to shared/core/generator infrastructure (or to this workflow itself) can affect - # every packaged leg at once - run all eight rather than reflexively narrowing to only the + # every packaged leg at once - run all nine rather than reflexively narrowing to only the # paths that happened to change (ADR-0041 Amendment 7's own "do not run it reflexively, but # do not under-run it either" balance). if echo "$changed" | grep -qE '^(src/Compono/|src/Compono\.Generators/|Directory\.Packages\.props|Directory\.Build\.(props|targets)|test/Directory\.Build\.(props|targets)|\.github/workflows/aot-validation\.yaml)'; then - echo "Core/generator/shared-config change detected - running all eight legs." + echo "Core/generator/shared-config change detected - running all nine legs." echo "legs=$all_legs" >> "$GITHUB_OUTPUT" exit 0 fi @@ -77,12 +77,13 @@ jobs: echo "$changed" | grep -q '^src/Compono\.Logging/' && add_leg "Compono.Logging" echo "$changed" | grep -q '^src/Compono\.MSTest/' && add_leg "Compono.MSTest" echo "$changed" | grep -q '^src/Compono\.NUnit/' && add_leg "Compono.NUnit" + echo "$changed" | grep -q '^src/Compono\.Options/' && add_leg "Compono.Options" echo "$changed" | grep -q '^src/Compono\.TestDoubles/' && add_leg "Compono.TestDoubles" echo "$changed" | grep -q '^src/Compono\.TUnit/' && add_leg "Compono.TUnit" echo "$changed" | grep -q '^src/Compono\.XunitV3/' && add_leg "Compono.XunitV3" # A change scoped to one leg's own AotSmokeTest project only needs that leg re-run, not all - # eight - extract which leg(s) directly from the changed paths. + # nine - extract which leg(s) directly from the changed paths. for proj in $(echo "$changed" | grep -oE '^test/[^/]+\.AotSmokeTest/' | sed -E 's#^test/(.+)\.AotSmokeTest/#\1#' | sort -u); do add_leg "$proj" done diff --git a/.github/workflows/package-validation.yaml b/.github/workflows/package-validation.yaml index e26d5f06..f434db97 100644 --- a/.github/workflows/package-validation.yaml +++ b/.github/workflows/package-validation.yaml @@ -37,7 +37,7 @@ jobs: # process environment by GitHub Actions itself, so this survives across the separate `run:` # steps below (each its own shell process) with no extra plumbing - deliberately not a Bash # array, which would only live for the one step that declared it. - PACKAGES: "Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit" + PACKAGES: "Compono Compono.XunitV3 Compono.NSubstitute Compono.Bogus Compono.TUnit Compono.TestDoubles Compono.DependencyInjection Compono.Http Compono.Logging Compono.MSTest Compono.NUnit Compono.Options" steps: - uses: actions/checkout@v7 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Compono.slnx b/Compono.slnx index 94d60622..abbc6aca 100644 --- a/Compono.slnx +++ b/Compono.slnx @@ -12,6 +12,7 @@ + @@ -26,6 +27,7 @@ + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 5263a9aa..090afba9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Compono determines **how** those requirements are satisfied. | `Compono.Logging` | `Microsoft.Extensions.Logging` testing support (`UseLogging()`, `CapturingLogger`) | [![NuGet](https://img.shields.io/nuget/v/Compono.Logging.svg)](https://www.nuget.org/packages/Compono.Logging) | [![NuGet Downloads](https://img.shields.io/nuget/dt/Compono.Logging.svg)](https://www.nuget.org/packages/Compono.Logging) | | `Compono.MSTest` | MSTest integration | [![NuGet](https://img.shields.io/nuget/v/Compono.MSTest.svg)](https://www.nuget.org/packages/Compono.MSTest) | [![NuGet Downloads](https://img.shields.io/nuget/dt/Compono.MSTest.svg)](https://www.nuget.org/packages/Compono.MSTest) | | `Compono.NUnit` | NUnit integration (no `[TestFixture]` required) | [![NuGet](https://img.shields.io/nuget/v/Compono.NUnit.svg)](https://www.nuget.org/packages/Compono.NUnit) | [![NuGet Downloads](https://img.shields.io/nuget/dt/Compono.NUnit.svg)](https://www.nuget.org/packages/Compono.NUnit) | +| `Compono.Options` | `Microsoft.Extensions.Options` testing support (`TestOptionsSource`, `UseOptions()`) | [![NuGet](https://img.shields.io/nuget/v/Compono.Options.svg)](https://www.nuget.org/packages/Compono.Options) | [![NuGet Downloads](https://img.shields.io/nuget/dt/Compono.Options.svg)](https://www.nuget.org/packages/Compono.Options) | ## Example diff --git a/docs/adr/0061-compono-options-testing-support.md b/docs/adr/0061-compono-options-testing-support.md new file mode 100644 index 00000000..966c2503 --- /dev/null +++ b/docs/adr/0061-compono-options-testing-support.md @@ -0,0 +1,787 @@ +# [ADR-0061] `Compono.Options`: First-Class .NET Configuration/Options Testing Support + +**Status:** Accepted + +**Date:** 2026-09-07 (revised 2026-09-08 — dogfooding validation, `IOptionsSnapshot` +semantics research, public object model, and registration/identity resolved, +plus source-disposal and concurrency final resolutions; see "Revision +(2026-09-08)" below Context and "Acceptance" at the end. `Accepted` +2026-09-08 — see "Acceptance.") + +**Decision Makers:** Nick Cipollina, Claude (design deep dive) + +## Context + +`Microsoft.Extensions.Options`'s `IOptions`/`IOptionsSnapshot`/ +`IOptionsMonitor` is the standard, idiomatic way a modern .NET +application receives strongly-typed configuration. Any real application +composed with Compono that reads configuration this way needs its test +doubles composed the same way every other dependency is. + +This ADR is the outcome of an admission investigation and reassessment — +[RESEARCH-0028](../research/0028-compono-options-configuration-admission-research.md) — +run against +[`docs/architecture/capability-admission.md`](../architecture/capability-admission.md)'s +Gate A/Gate B process, triggered by an explicit product-owner request and +then reassessed the same day against a sharpened version of that request +(composition ergonomics as legitimate value, not merely "wrap an +already-simple API"). Both gates cleared; see that research document for +the full evidence trail and per-slice decomposition (Configuration, +`IOptions` alone, and Options validation were each investigated and +separately concluded *not* to need new capability). This ADR records the +problem this capability solves and the recommended architecture — it does +not re-derive RESEARCH-0028's evidence. + +**The problem has two independent, evidenced dimensions**, neither of +which alone would have been sufficient (RESEARCH-0028 §1, §13): + +1. **Correctness.** `IOptionsMonitor`/`IOptionsSnapshot` have no + first-party test double. The community's own standard answer — a + hand-rolled fake, most visibly + [Ben Foster's widely-cited `TestOptionsMonitor`](https://benfoster.io/blog/20200610-testing-ioptionsmonitor/) — + is demonstrably incomplete: `Get(name)` ignores `name` entirely (named + options silently broken), only one `OnChange` subscriber is ever + honored (a plain field assignment, not a real multicast event), and the + returned `IDisposable` is a no-op (`Mock.Of()`) that + doesn't actually unsubscribe anything. +2. **Composition coherence.** A Compono consumer whose SUT depends on + `IOptions` *and* `IOptionsMonitor`/`IOptionsSnapshot` for the + same settings type has to hand-wire each one separately today, with + nothing preventing them from silently drifting inconsistent. This is + the same category of value `CompositionBuilder.Share()` + ([ADR-0056](0056-composition-builder-share-graph-wide-sharing.md)) and + `Compono.Bogus` ([ADR-0027](0027-compono-bogus-package-design.md)) + were admitted on — composition-native, discoverable, consistent + behavior a consumer would otherwise reinvent, slightly differently, + every time. + +**What "coherent" does and does not mean here** — the central semantic +question this ADR has to answer, not assume: `IOptions`, +`IOptionsSnapshot`, and `IOptionsMonitor` are **not** interchangeable +views over one mutable value. A design that makes all three reactively +track one mutable value would be **more surprising than the real thing**, +not more correct — `IOptions` genuinely doesn't change in production, +and a Compono fake that makes it change would silently mismatch the real +contract it claims to model. **Precise definition, settled by this +revision (see below):** "coherent" means all three interfaces originate +from **one explicitly test-configured source of truth** per settings type +— but each interface still exposes exactly its own real, distinct +observable contract over that source. A shared origin, not shared +behavior. + +## Revision (2026-09-08): dogfooding validation, real `OptionsManager` semantics, and the resulting object model + +A follow-up design review (2026-09-08) found that this ADR's original +Snapshot mapping ("one Compono resolution stands in for one DI scope") was +a Compono-invented analogy asserted without checking Microsoft's actual +implementation, and that "single class vs. two" and the exact registration +identity/lifetime contract were left as open questions even though they +determine public semantics, not just implementation shape. This revision +resolves both **before** this ADR is fit for acceptance. Nothing in the +original Context's problem statement, the correctness findings (subscriber +exceptions, thread-safety posture), the Finding B boundary, or the +verification/scope decisions changed — only the Snapshot semantics, the +public object model, and the registration/identity contract, all +superseded by this section and "Decision Outcome" below. + +### Dogfooding validation against real consumers + +Per RESEARCH-0028's identified consumers, both were inspected directly +(read-only; neither repository was modified) for every real +`IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` usage: + +- **`alexa-vox-craft`** (`/Users/ncipollina/source/repos/layered-craft/alexa-vox-craft`) — + 32 files reference `IOptions`; **zero** reference + `IOptionsSnapshot`/`IOptionsMonitor`. Two real test-profile + registrations found: + - `test/AlexaVoxCraft.MediatR.Tests/TestKit/MediatRTestProfile.cs`: + `.Register(_ => new SkillServiceConfiguration {...})` + immediately followed by + `.Register>(context => Options.Create(context.Resolve()))` + — **two separately-maintained registrations for the same settings + type, in the same profile, with nothing enforcing they stay + consistent** — exactly the coherence risk this ADR's Context + identifies, concretely present in real code, today. + - `test/AlexaVoxCraft.Smapi.Tests/TestKit/SmapiHttpTestProfile.cs`: + `.Register>(context => Options.Create(new SmapiDeveloperAccessTokenOptions { ClientId = context.Resolve(), ... }))` + — the file's own comment documents this shape exists **specifically + because** `context.Resolve()` + throws `CompositionException` (ADR-0052 Finding B, reproduced live in + this real file) — the record type is never independently discovered + as a root anywhere in the project. +- **`cosmere-tracker`** (`/Users/ncipollina/source/repos/ncipollina/cosmere-tracker`) — + 3 files reference `IOptions`; **zero** reference + `IOptionsSnapshot`/`IOptionsMonitor`. + `test/Cosmere.Tracker.Shared.Tests/TestKit/Profiles/PersistenceTestProfile.cs`: + `.Register>(() => Options.Create(new DynamoDbOptions {...}))` + — a simpler case, an inline literal with no nested resolve at all, no + Finding B involvement. + +**Selected dogfooding validation target: `MediatRTestProfile.cs`'s +`IOptions` registration.** Chosen over the +`Smapi`/`cosmere-tracker` cases because it's the one real, non-Finding-B-entangled +example of exactly the coherence risk this ADR exists to close — two +registrations for one settings type, wired by hand, with no structural +guarantee they agree. `Compono.Options` is expected to collapse these two +lines into one coherent registration backed by a single source, with the +consistency guarantee built in rather than hand-maintained. It is +**explicitly not** expected to improve the `SmapiHttpTestProfile.cs` case +— that one is Finding-B-shaped, and this ADR's design deliberately doesn't +touch Finding B (unchanged from the original ADR). + +**Honest finding, not manufactured:** neither repository exercises +`IOptionsSnapshot` or `IOptionsMonitor` at all. There is **no real +dogfooding evidence for the Monitor/Snapshot correctness dimension** — +that half of this capability's justification rests entirely on external, +well-documented community-fake-defect evidence (Ben Foster et al., +RESEARCH-0028 §7), not on friction observed in a real Compono consumer. +This doesn't weaken Gate A/Gate B (Gate B was satisfied by explicit +product-owner request, not dogfooding, exactly as `Compono.TUnit`/ +`Compono.NUnit` were) but it does mean this design's Monitor/Snapshot +surface has not yet been pressure-tested against a real consumer's actual +usage pattern — recorded as a genuine gap, not glossed over. + +### Real `IOptions`/`IOptionsSnapshot` semantics — researched, not assumed + +Confirmed directly against +[dotnet/runtime's `OptionsManager.cs`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Options/src/OptionsManager.cs) +(the concrete type behind both interfaces in real Microsoft code) and +[Options pattern - .NET | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/core/extensions/options): + +- **`OptionsManager` implements *both* `IOptions` and + `IOptionsSnapshot` — the same concrete class, not two related + ones.** `IOptions.Value` is literally `Get(Options.DefaultName)`; + `IOptionsSnapshot.Value`/`Get(name)` is the exact same code path. + There is **no type-level behavioral difference between `IOptions` and + `IOptionsSnapshot` at all.** +- Results are cached **per-instance**, keyed by name (`OptionsCache`). + Calling `Get(name)` twice on the *same instance* returns the cached + value both times — no recomputation. +- **`OptionsManager` itself has zero scope-awareness.** It + doesn't know or care whether it's registered Singleton or Scoped. +- **The entire `IOptions` vs. `IOptionsSnapshot` behavioral + difference is purely a consequence of DI *registration lifetime*, not + anything in the type**: `IOptions` is registered Singleton (one + `OptionsManager` instance, and therefore one cache, for the whole + app's lifetime — hence "never changes"); `IOptionsSnapshot` is + registered Scoped (a *fresh* `OptionsManager` instance, with a fresh, + empty cache, per DI scope — hence "recomputed once per scope, fixed + within it"). + +This is a materially better-grounded finding than this ADR's original +"one Compono resolution stands in for one DI scope" analogy — that +analogy turns out to be **exactly right**, but for a reason this ADR +didn't originally have evidence for: real Microsoft achieves "fixed +within a lifetime, fresh across lifetimes" purely by controlling *how many +instances exist and when they're constructed*, using one interchangeable +type. Compono already has an exact, existing, `Accepted` primitive for +controlling instance count within a graph: `CompositionBuilder.Share()` +vs. an ordinary (non-shared) `Register` factory. No new lifecycle +concept needs to be invented — this maps onto real Compono mechanics +already in production, not a Compono-specific fiction. + +## Decision Drivers + +- **Faithfulness over convenience.** A test double for a specific + interface contract is only useful if its observable behavior matches + that contract. +- **No reflection, no hidden state** ([ADR-0001](0001-source-generation-first.md)) — + a hand-written, non-generated runtime package, the same shape + `Compono.Http` already established. +- **Core `Compono` must never reference an integration package** + ([design-principles.md](../architecture/design-principles.md)) — this + package depends on `Microsoft.Extensions.Options` and core `Compono` + only, never the reverse. +- **ADR-0052 Finding B is a hard boundary, not a design target.** Unchanged + from the original ADR — see "ADR-0052 Finding B boundary" below. +- **Read naturally both inline and inside a profile.** Unchanged — see + `MediatRTestProfile.cs`'s real usage above for what "naturally" means in + practice. +- **Match real `OptionsMonitor`'s actual robustness posture, not an + imagined stricter one.** Unchanged from the original ADR (subscriber-throw, + locking posture) — confirmed directly against source, not re-litigated + in this revision. +- **Prefer existing Compono primitives over inventing a new lifecycle + concept.** New this revision, directly from the `OptionsManager` + finding above: if real Microsoft achieves the `IOptions`/ + `IOptionsSnapshot` distinction purely through instance-count control, + this design should too, using `Share()`/plain `Register` rather + than inventing a Compono-specific "snapshot lifecycle." +- **Package-boundary discipline** ([ADR-0039](0039-future-extension-package-admission-gate-and-release-sequence.md)) — + `Compono.DependencyInjection` remains the wrong home (unchanged, + RESEARCH-0028 §12). + +## Considered Options + +**Consumer entry point** (unchanged from the original ADR — see "Why +Option 1" below, reasoning unchanged): +1. A single hand-written class per settings type, constructed directly by + the test, wired into composition via one builder call. +2. Three independent builder extension methods, each registering one + interface separately. +3. A universal auto-composing stage-4-6 provider. + +**Public object model** (new this revision): +1. **One public source type per settings type**, holding the current + default/named values and the change-notification machinery, directly + implementing `IOptionsMonitor` itself (the one genuinely live, + singleton-shaped interface) plus a Compono-native mutation surface + (illustratively, `.Set(...)`/named overloads). A small **internal** + frozen-view type — never named by consumer code — implements + `IOptions`/`IOptionsSnapshot`, constructed fresh from the + source's current state each time one is produced. +2. **One public type implementing all three Microsoft interfaces at + once** (`IOptions`, `IOptionsSnapshot`, `IOptionsMonitor` + simultaneously on one object). +3. **Three separate public types**, one per Microsoft interface, each + independently wrapping a shared internal state object. + +**`IOptions`/`IOptionsSnapshot` instance/identity model** (new this +revision, replacing the original "frozen at resolution" framing with a +mechanism, not just a behavior): +1. Register `IOptions` **with `Share()`** (one frozen-view instance + for the whole composition graph — matches Singleton/"one + `OptionsManager` for the app's life"); register + `IOptionsSnapshot` as an **ordinary, non-shared** `Register` + factory (Compono invokes the factory fresh each time it's resolved, + producing a new frozen-view instance per resolution — matches Scoped/ + "fresh `OptionsManager` per scope"). +2. Register both with `Share()` (one frozen instance for the whole + graph, no distinction between the two interfaces at all). +3. Register both as ordinary, non-shared factories (a fresh frozen view + every single resolution, for both interfaces). + +**Change-notification robustness, unconfigured-named-option behavior, +verification:** unchanged from the original ADR (see "Decision Outcome," +carried forward below) — this revision found no new evidence requiring +either to change. + +## Decision Outcome + +**Chosen, per axis:** entry point — **Option 1** (unchanged); public +object model — **Option 1**; `IOptions`/`IOptionsSnapshot` identity +model — **Option 1**; change-notification robustness, unconfigured-named-option +behavior, and verification — all **carried forward unchanged** from the +original ADR (reasoning below, condensed; full reasoning is this ADR's +git history / the original 2026-09-07 text, superseded here per this +repo's "later fact gets its own dated update" convention rather than +silently rewritten). + +### Entry point: Option 1 (unchanged reasoning) + +Option 3 (universal auto-composing provider) is rejected: it would call +`context.Resolve()` for an arbitrary `T` inside its own `TryProvide`, +hitting ADR-0052 Finding B exactly whenever `T` isn't independently +discovered elsewhere — the identical wall `SmapiHttpTestProfile.cs` +(above) already hits by hand. Option 2 (three independent registrations) +is rejected: it's exactly `MediatRTestProfile.cs`'s existing shape, and +doesn't solve the coherence problem at all. **Option 1**: the test +constructs a single per-settings-type source object directly, then one +builder call wires all three interfaces from it — avoids Finding B +entirely (the test supplies the value; nothing resolves `T` from inside a +factory), needs no new core extension point, reads identically inline and +inside a profile. + +### Public object model: Option 1, chosen against your stated preference — with the reasoning that resolves it + +You noted a strong preference against "one class implementing multiple +Microsoft interfaces merely because implementation can be shared." That +preference is correct **as a default heuristic** and this decision doesn't +override it casually — it resolves it against the specific evidence +above, which changes the premise: **`IOptions` and `IOptionsSnapshot` +are not two interfaces with different lifetime semantics that happen to +share implementation for convenience — in real Microsoft code they are +the exact same behavior, exposed through two interface names, with the +only real difference being how many instances of that one behavior exist** +(§"Real `IOptions`/`IOptionsSnapshot` semantics," above). Combining +them here is not a shortcut; it's matching the real architecture 1:1, +the same standard this ADR already holds every other fidelity decision to. + +**`IOptionsMonitor` is different in kind, not just in cardinality** — it +is a genuinely separate, live-reactive contract in real Microsoft code +too (`OptionsMonitor`, a distinct class from `OptionsManager`). This +design keeps it conceptually distinct: the one **public** type per +settings type is the *source* — the thing the test constructs, mutates, +and subscribes to — and it directly implements `IOptionsMonitor`, +because Monitor's contract ("read the current live value, subscribe to +changes") is exactly what a mutable source naturally *is*, not a +retrofit. The frozen-view type behind `IOptions`/`IOptionsSnapshot` +is a small, **internal** implementation detail a consumer never names — +it's reached only through the standard interface types, the same way a +consumer today never thinks about `OptionsManager`'s own concrete type +either. Option 2 (one type implementing all three at once, including +Monitor) is rejected for the reason you gave: Monitor's live semantics +and the frozen views' fixed semantics are genuinely different in kind, and +collapsing them into one object would obscure that distinction rather +than express it. Option 3 (three fully separate public types) is +rejected: it would either duplicate the frozen-view logic for `IOptions` +and `IOptionsSnapshot` (contradicting the finding that they're the same +behavior) or force an artificial public split between two things that are +genuinely one. + +**Resulting public model:** one public type per settings type (name not +finalized) — call it conceptually the **Options source** — is what a test +constructs, configures, mutates, and passes to the one wiring call. It +directly implements `IOptionsMonitor`. `IOptions` and +`IOptionsSnapshot` are satisfied by an internal frozen-view type the +wiring call constructs from the source; a consumer's code only ever sees +these as the ordinary Microsoft interfaces. + +### `IOptions`/`IOptionsSnapshot` identity model: Option 1, built entirely on existing Compono primitives + +Directly following from the `OptionsManager` finding: the wiring call +registers `IOptions` **via `Share()`** — one frozen-view instance, +constructed once (capturing the source's value at that moment) and reused +for every subsequent request for `IOptions` within the same graph, +matching Singleton/"one instance for the app's life." It registers +`IOptionsSnapshot` as an **ordinary `Register` factory, deliberately +without `Share()`** — per Compono's own existing, unchanged +`StoreSharedValue` semantics (`src/Compono/CompositionContext.cs`), a +plain registration's factory is re-invoked on every resolution unless the +type is shared, so each `IOptionsSnapshot` resolution naturally +produces a **fresh** frozen view capturing the source's *current* state at +that moment — matching Scoped/"fresh instance per scope," with "one +Compono resolution" now precisely and correctly standing in for "one DI +scope" **because Compono's own registration semantics already produce +exactly that shape**, not because this ADR invented a special case for it. + +`IOptionsMonitor` is registered the same way `IOptions` is +(`Share()` — one instance per graph) since it *is* the source object +itself, and the source only ever needs one identity per graph regardless +of how many places request it. + +**This directly answers your registration/composition questions:** + +- Repeated resolution of `IOptions` returns the **same** frozen wrapper + (shared). +- Repeated resolution of `IOptionsSnapshot` returns a **new** frozen + view **each time**, reflecting whatever the source's current state is + at that moment (not shared) — matching real Scoped-per-request + semantics as closely as a scope-free environment honestly can. +- `IOptionsMonitor` has **stable identity** — the same source instance + every time (shared), consistent with its real Singleton registration. +- An explicit `Register>(...)`/`Register>(...)`/ + `Register>(...)` written by the consumer **after** + `Compono.Options`'s own wiring call interacts through Compono's ordinary, + unchanged first-registration-wins rule + (`src/Compono/CompositionBuilder.cs`) — no special-cased precedence is + invented for this package. A consumer who explicitly overrides one of + the three still gets ordinary, predictable Compono behavior, just + without this package's coherence guarantee for the overridden interface + specifically. +- `Share()`'s role: internal to how the wiring call registers + `IOptions`/`IOptionsMonitor` — a consumer never calls `Share()` + themselves for these types; the package uses the primitive on the + consumer's behalf. +- Profiles: unaffected — the wiring call is one more ordinary + `CompositionBuilder` call, exactly like `MediatRTestProfile.cs`'s + existing `Register>(...)` call it's meant to replace. + +### Change-notification robustness, unconfigured-named-option behavior, verification (carried forward, condensed) + +- **Change notification** matches real `OptionsMonitor` exactly — a + plain event, synchronous invocation, no per-subscriber exception + isolation, no locking beyond what a compiler-generated event already + guarantees. Confirmed directly against `OptionsMonitor.cs` + (`_onChange` is a plain `event Action?`; a throwing + subscriber blocks remaining ones; no explicit locking anywhere). This + package's one real correctness addition over the naive community fake: + a genuine per-subscription `IDisposable` that actually unsubscribes + (`-=` against the internal event) — the one concrete bug (RESEARCH-0028 + §7) a correctly-used C# event fixes for free. +- **Unconfigured named option: throws, reaffirmed with a sharper + justification.** Researched further this revision: real + `IOptionsFactory.Create(name)` for a name with no matching + `IConfigureOptions`/`IConfigureNamedOptions` silently returns a + plain `new TOptions()` — no exception, no signal anything was + unconfigured (confirmed against the documented `IOptionsFactory` + contract on the Learn page: it applies whichever registered + configuration delegates match a name and returns the result + regardless of whether any did). This ADR's throw-instead choice is + therefore a **real, disclosed divergence from production behavior**, + not a fidelity-neutral default — but it is not a novel invention + either: it's the exact same tradeoff Compono's own `Compono.TestDoubles` + already made and shipped, in the very consumer this ADR validated + against. `MediatRTestProfile.cs`'s own comments (above) describe + `IHandlerInput.RequestEnvelope` and `IAttributesManager.Session` as + deliberately generated *configuration-required* under + [ADR-0045](0045-testdoubles-configuration-required-members.md) — + "more honest than implicit auto-population or silent-null defaults, at + the cost of one explicit line per test that needs it" — the identical + justification this ADR is making for unconfigured named options, + already an established, `Accepted`, real-consumer-validated Compono + precedent, not a general principle asserted in the abstract. Reaffirmed: + throwing remains the decision. +- **Verification** remains deferred, not rejected — no evidenced demand, + including from the dogfooding validation above (neither real consumer + needed to verify change-notification call counts). + +### Source disposal: not `IDisposable`/`IAsyncDisposable` (resolved 2026-09-08, final) + +The public Options source object does **not** implement `IDisposable` or +`IAsyncDisposable`. It owns no production resource requiring disposal — no +file watcher, no real `IChangeToken`, no DI scope, no configuration +provider. The correct ownership boundary is per-subscription: the +`IDisposable` returned by `OnChange` is what a test disposes to +unregister that specific listener, exactly matching real +`OptionsMonitor`'s own per-registration disposal shape (its `Dispose()` +tears down change-token subscriptions that don't exist in this design at +all — there is nothing analogous for the source itself to dispose). +Giving the source its own `Dispose()` would imply a lifecycle/ownership +contract Compono's broader composition model deliberately doesn't have — +`CompositionRow`/`CompositionScope`/`Composer` own no disposal contract of +their own either (`Compono.DependencyInjection`'s `AsServiceProvider()` +bridge follows the identical rule, per ADR-0047). This is not left as an +implementation convenience question — no `Dispose()` method is added to +the source under any circumstance, including "just to clear all +subscribers at once"; a test that needs that disposes each subscription +individually. + +### Concurrent access to one source instance (resolved 2026-09-08, final) + +**Reframed from the original ADR's "concurrent test execution" framing, +which was the wrong question.** A Compono.Options source belongs to one +composition/test — sharing one mutable source instance across independent +*parallel tests* is not a supported scenario and does not drive this +design, the same way no other Compono composition primitive is designed +for cross-test sharing. The real, legitimate concern is concurrency +**within** one test/composition: a SUT may read `CurrentValue` on one +thread while another triggers a change, multiple consumers may subscribe/ +unsubscribe concurrently, and a named value may be read while a different +name is being changed. + +**Behavioral contract (architectural; the primitive that satisfies it is +implementation-level, per Open Questions below):** + +- The source remains internally valid under concurrent reads, changes, + subscriptions, and unsubscriptions — no operation may observe corrupted + or partially-mutated internal state. +- Named-value storage must not become structurally corrupted by concurrent + mutation of different names, or of the same name. +- Subscribe/unsubscribe remains safe under concurrent calls, including + concurrent with an in-flight change notification. +- **A change operation establishes the new current value before invoking + any change callback** — a callback that reads `CurrentValue`/`Get(name)` + during its own invocation observes the changed value, never the stale + one, matching real `OptionsMonitor`'s own cache-then-invoke ordering + (`InvokeChanged`: `_cache.TryRemove(name)` then recompute, *then* + `_onChange?.Invoke(...)`). +- Notifications remain synchronous (unchanged from the original ADR). +- Notification ordering follows ordinary multicast-event behavior, already + decided above — no additional ordering guarantee beyond that. +- **No stronger transactional or cross-operation ordering guarantee is + promised** — e.g., no atomicity across a multi-name batch change, no + guaranteed ordering between two threads racing to change different + names, beyond each individual operation being internally safe. Inventing + either would be machinery with no evidenced need, exactly the kind of + speculative robustness this ADR's Decision Drivers already reject + elsewhere (§"Change-notification robustness"). + +This contract removes concurrency from the unresolved/open-question list +— the *behavior* is now decided. The concrete synchronization primitive +(a `lock`, a `ConcurrentDictionary`, or another approach) is deliberately +left to implementation planning, since more than one primitive can satisfy +this contract and the choice has no public-observable consequence. + +### Positive Consequences + +- A correct, reusable `IOptionsMonitor` fake closes a real, + community-documented correctness gap. +- The object model and identity contract close the exact coherence risk + found live in `MediatRTestProfile.cs` — one source, one wiring call, + `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` structurally + unable to drift apart. +- The `IOptions`/`IOptionsSnapshot` identity model is built entirely + from existing, `Accepted` Compono primitives (`Share()`, ordinary + `Register` semantics) — no new lifecycle concept invented. +- No new core extension point, no reflection, no generator dependency. + +### Negative Consequences + +- No automatic, no-registration composition for arbitrary `T` (unchanged + from the original ADR) — accepted, blocked on Finding B, out of scope. +- The Monitor/Snapshot correctness case has no real dogfooding validation + — accepted, since Gate B was satisfied by explicit product-owner + request, but recorded honestly as a real gap this design has not been + pressure-tested against. +- Matching real `OptionsMonitor`'s lack of per-subscriber exception + isolation (unchanged) — accepted, faithfulness over friendliness. +- The unconfigured-named-option-throws choice diverges from real + `IOptionsFactory` — accepted, on the strength of the `Compono.TestDoubles`/ + ADR-0045 precedent, not merely general principle. + +## Pros and Cons of the Options + +### Public object model: source implements Monitor; internal frozen-view type implements IOptions/Snapshot (chosen) + +- Good, because it matches real Microsoft architecture exactly + (`OptionsManager` implementing both frozen interfaces; a distinct + `OptionsMonitor` for the live one) rather than inventing a different + shape. +- Good, because it keeps the one thing a consumer actually holds (the + source) conceptually simple — "the live, mutable thing," with the + frozen views as an implementation detail reached only through the + standard interfaces. +- Bad, because a consumer inspecting the source's own type will see it + implements `IOptionsMonitor` specifically (not the frozen + interfaces) — a minor discoverability cost, mitigated by the wiring + call and documentation making the full picture obvious. + +### Public object model: one type implements all three + +- Good, because it's the smallest possible public surface. +- Bad, because it obscures the genuine behavioral difference between + Monitor (live) and the two frozen interfaces (fixed) on one object — + exactly the ambiguity you flagged as a concern, and correctly so. + +### Public object model: three fully separate public types + +- Good, because each interface's public type is maximally simple in + isolation. +- Bad, because it either duplicates frozen-view logic for two interfaces + that are genuinely the same behavior in real Microsoft code, or forces + an artificial split that doesn't reflect reality. + +### `IOptions`/`IOptionsSnapshot` identity: `Share()` for `IOptions`, plain `Register` for Snapshot (chosen) + +- Good, because it reproduces real Singleton-vs-Scoped behavior using + Compono's own existing, `Accepted` primitives — no new lifecycle + concept. +- Good, because "one Compono resolution = one DI scope" is no longer an + asserted analogy; it's a direct, mechanical consequence of how + `Register` already behaves. +- Bad, because a consumer has to understand that `IOptionsSnapshot` + resolved twice in the same graph yields two different frozen instances + — a real, if minor, surprise risk, mitigated by documentation and by + the fact that this exactly matches how two different DI scopes would + behave too. + +### `IOptions`/`IOptionsSnapshot` identity: both shared, no distinction + +- Good, because it's the simplest possible identity model. +- Bad, because it silently drops the real, documented difference between + Singleton and Scoped registration — a fidelity gap, not a + simplification. + +### `IOptions`/`IOptionsSnapshot` identity: both fresh every resolution + +- Good, because it never risks stale data. +- Bad, because it makes `IOptions` behave like `IOptionsSnapshot` + — contradicts real `IOptions`'s actual "one instance, once computed" + contract just as much as making it fully reactive would have. + +## Links + +- [RESEARCH-0028](../research/0028-compono-options-configuration-admission-research.md) — + the full admission investigation and reassessment. +- [`docs/architecture/capability-admission.md`](../architecture/capability-admission.md) — + the governing admission process. +- [ADR-0056](0056-composition-builder-share-graph-wide-sharing.md), + [ADR-0027](0027-compono-bogus-package-design.md) — the composition- + ergonomics-as-value precedent this capability's coherence dimension + follows. +- [ADR-0051](0051-compono-http-handler-based-testing-package.md) — the + closest architectural precedent. +- [ADR-0047](0047-compono-dependencyinjection-configured-resolution-bridge.md) — + confirms why this doesn't belong in `Compono.DependencyInjection`. +- [ADR-0052](0052-compile-time-composition-discovery-boundary-for-registered-and-nested-resolved-types.md) — + Finding B, reproduced live in `SmapiHttpTestProfile.cs` (above). +- [ADR-0018](0018-composition-profiles.md) — profiles need no new + mechanism to host this capability's registration call. +- [ADR-0045](0045-testdoubles-configuration-required-members.md) — the + direct, real-consumer-validated precedent for this ADR's + unconfigured-named-option-throws decision. +- [ADR-0001](0001-source-generation-first.md) — no-reflection-by-default. +- [dotnet/runtime `OptionsMonitor.cs`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs) — + change-notification fidelity source. +- [dotnet/runtime `OptionsManager.cs`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Options/src/OptionsManager.cs) — + the 2026-09-08 revision's primary source for the `IOptions`/ + `IOptionsSnapshot` identity model. +- [Testing IOptionsMonitor - Ben Foster](https://benfoster.io/blog/20200610-testing-ioptionsmonitor/) — + the naive community fake this capability corrects. +- `src/Compono/CompositionContext.cs` (`StoreSharedValue`), + `src/Compono/CompositionBuilder.cs` (`Register`) — the existing, + unchanged Compono mechanics the identity model is built on, inspected + directly this revision. +- `/Users/ncipollina/source/repos/layered-craft/alexa-vox-craft` + (`MediatRTestProfile.cs`, `SmapiHttpTestProfile.cs`) and + `/Users/ncipollina/source/repos/ncipollina/cosmere-tracker` + (`PersistenceTestProfile.cs`) — the dogfooding validation sources + inspected this revision (read-only; neither repository modified). + +## Documentation consequences (recorded now, executed later) + +### `Compono.Options` package documentation (once implemented) + +Must cover: installation; basic setup; `IOptions`; `IOptionsSnapshot`; +`IOptionsMonitor`; the identity model (shared `IOptions`/Monitor vs. +fresh-per-resolution `IOptionsSnapshot`) explained in terms a consumer +can reason about without needing to know `Share()` is involved +internally; named options; deterministic changes; subscription/disposal +semantics; inline usage; profile usage (with `MediatRTestProfile.cs`'s +real before/after as a worked example, once implementation exists); +relationship to ordinary `Microsoft.Extensions.Options` APIs; intentional +non-goals (no real `IConfiguration`/change-token simulation, no +`IOptionsFactory` pipeline, no DI-scope simulation); the +unconfigured-named-option-throws divergence from real `IOptionsFactory`, +stated plainly; the ADR-0052 Finding B limitation. + +### Configuration Cookbook — unchanged, still a required deliverable + +Preserved exactly as the original ADR recorded it — not weakened or +dropped by this revision. Recorded in +[`docs/roadmap/future-packages.md`](../roadmap/future-packages.md)'s +"Documentation-only ideas" section: + +- Basic in-memory `IConfiguration` composition (`ConfigurationBuilder`, + `AddInMemoryCollection`, `Register`). +- Layered configuration / test-specific overrides. +- Reusable configuration through a profile. +- `GetSection`/common consumption patterns. +- Explicit routing guidance: ordinary Configuration for `IConfiguration` + itself; `Compono.Options` for the Options interfaces it owns; no + `Compono.Configuration` package exists. + +Whichever PR implements `Compono.Options` should treat this Cookbook work +as part of its own definition of done. + +## Skill/eval consequences (recorded now, executed at implementation time) + +Unchanged from the original ADR: review/update `skills/compono/SKILL.md`'s +detection table, add `references/options.md`, update `evals.json`, run the +mandatory baseline-vs-updated skill-eval comparison — **not performed +now**. + +## Open questions + +### Resolved this revision (were previously listed as open; now settled) + +- ~~Single class vs. two~~ — resolved: one public source type + implementing `IOptionsMonitor` directly; an internal frozen-view type + (never public) behind `IOptions`/`IOptionsSnapshot`. +- ~~`IOptionsSnapshot` lifecycle/semantics~~ — resolved: matches real + `OptionsManager` architecture via `Share()` (for `IOptions`) + vs. plain `Register` (for `IOptionsSnapshot`); no invented + Compono-specific snapshot concept. +- ~~Registration identity/lifetime for all three interfaces~~ — resolved, + precisely (see "Decision Outcome" above): `IOptions`/ + `IOptionsMonitor` shared (one instance per graph); + `IOptionsSnapshot` fresh per resolution. +- ~~Whether `Share()` has a role~~ — resolved: yes, used internally by + the wiring call for `IOptions`/`IOptionsMonitor`; never called + directly by the consumer. +- ~~Unconfigured named-option behavior~~ — reaffirmed (throw), now backed + by the ADR-0045/`Compono.TestDoubles` precedent found in the dogfooding + validation, not just general principle. +- ~~Disposal of the source object itself~~ — resolved 2026-09-08: no + `IDisposable`/`IAsyncDisposable` on the source; per-subscription + disposal only. See "Source disposal," above. +- ~~Concurrent read/change interaction~~ — resolved 2026-09-08: reframed + from cross-test sharing (not a supported scenario) to concurrency + *within* one source instance, with an explicit behavioral contract. See + "Concurrent access to one source instance," above. The synchronization + *primitive* that satisfies the contract remains implementation-level + (see below). + +### Still genuinely open — implementation-level, not public-contract + +- **Exact public API naming** — the source type's name, its + `.Set(...)`-shaped mutation method(s), and the wiring call's name are + all illustrative only. Resolved by PLAN-0064's own naming-finalization + task before implementation begins, not by this ADR. +- **Thread-safety implementation primitive** for the named-value store — + the *contract* it must satisfy (§"Concurrent access to one source + instance") is decided; whether a `lock`, a `ConcurrentDictionary`, or + another approach satisfies it most simply is an implementation choice + with no public-observable consequence. + +Both remaining items are naming/implementation choices only — neither +changes what a consumer observes, and neither blocks acceptance. + +## Acceptance + +Every architectural/public-contract question this ADR's design pass +identified is now resolved: the entry point, the public object model, the +`IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` identity and +registration contract, named-option semantics (including the unconfigured-name +divergence and its precedent), change-notification robustness, source +disposal, and the concurrent-access contract. The one honest, disclosed +gap — no real Compono consumer dogfoods `IOptionsMonitor`/ +`IOptionsSnapshot` specifically (§"Dogfooding validation," above) — +doesn't block Gate A/Gate B (satisfied by explicit product-owner request, +independent of dogfooding by design, exactly as `Compono.TUnit`/ +`Compono.NUnit` were) and doesn't leave any architectural question +unresolved; it is carried forward into PLAN-0064 as deterministic +contract-test coverage in lieu of real-consumer Monitor/Snapshot evidence, +rather than glossed over. Accepted 2026-09-08. + +## Amendment 1 (2026-09-08): registration-precedence correction + +PLAN-0064's implementation surfaced a factual error in the "Decision +Outcome" identity-model section above: the claim that an explicit consumer +`Register>(...)`/`Register>(...)`/ +`Register>(...)` written after `UseOptions`'s own +wiring call "interacts through Compono's ordinary, unchanged +first-registration-wins rule" and that "a consumer who explicitly +overrides one of the three still gets ordinary, predictable Compono +behavior." **This describes behavior that does not exist.** Real +`CompositionBuilder.Register` (`src/Compono/CompositionBuilder.cs`, +ADR-0019) has no first-registration-wins or last-registration-wins +override semantics at all for an exact-type collision: registering the +same exact type more than once — directly, via a profile, or across two +profiles — is a strict build-time conflict, thrown as +`CompositionConfigurationException` at `Composer.Create`'s validation +step, regardless of call order. Since `UseOptions` itself calls +`Register>(...)`/`Register>(...)`/ +`Register>(...)` internally, a consumer's own explicit +registration for any of the three collides with it and throws — it does +not silently override it. Confirmed by a real test +(`OrdinaryFirstRegistrationWinsPrecedence_HoldsUnchanged_ForAnExplicitConsumerOverride`, +`test/Compono.Options.Tests/CompositionBuilderExtensionsTests.cs`) that +asserts exactly this: `Composer.Create` throws +`CompositionConfigurationException` when a consumer registration collides +with `UseOptions`'s own. + +This does not change the ADR's core decision (the object model, the +identity/lifetime contract, or `Share()`'s internal role) — those hold +exactly as decided. It only corrects the override claim: **a consumer +cannot selectively override one of the three Options interfaces while +keeping `UseOptions`'s coherence guarantee for the other two** — there +is no partial-override path. A consumer who genuinely needs different +behavior for one interface must not call `UseOptions` for that settings +type at all, and wires all three (or whichever it needs) by hand instead, +same as before this package existed. "No special-cased precedence is +invented for this package" remains true and is in fact the reason for this +correction: Compono's real, unchanged, strict duplicate-registration rule +applies here exactly as it does everywhere else, with no override +exception carved out for `Compono.Options` — the ADR's original prose +described that rule incorrectly, not this package behaving inconsistently +with it. + +**Second, related correction — the "collapse these two lines into one" +framing in "Selected dogfooding validation target" above, and what +`Compono.Options` actually guarantees coherent.** `Compono.Options` +guarantees coherence **among `IOptions`/`IOptionsSnapshot`/ +`IOptionsMonitor`** — all three originate from one `TestOptionsSource` +by construction, and this is real and enforced. It does **not** guarantee +coherence between the bare settings type `T` and those three interfaces +merely because `T` happens to be registered elsewhere — `UseOptions` +never touches a plain `Register()` registration at all, by design (§ +"ADR-0052 Finding B boundary": the source must be a value the test +supplies directly, not something Compono resolves and re-wraps). The real +`MediatRTestProfile.cs` dogfooding result (PLAN-0064) still needs a plain +`Register(...)` registration alongside +`UseOptions`, because a separate consumer in that same test project +(`ServiceRegistrarTests`) depends on the bare type, not an Options +interface — the two-registration shape did not literally collapse into +one call. What changed is that both registrations are now sourced from +the *same* local instance by the test author's own discipline (a single +`var defaultSkillServiceConfiguration = new SkillServiceConfiguration {...}` +passed to both `Register(() => defaultSkillServiceConfiguration)` and +`new TestOptionsSource(defaultSkillServiceConfiguration)`) — coherent +by construction *in that consumer*, not because `UseOptions` enforces +any relationship to a separately-registered bare `T`. This is a narrowing +of the original claim's scope, not a reversal of the admission decision +(RESEARCH-0028/Gate A) — the coherence value `Compono.Options` provides +among the three Options interfaces themselves remains exactly as +evidenced and is what the dogfooding validation actually confirmed. diff --git a/docs/adr/README.md b/docs/adr/README.md index a28a964a..31fc23d7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -122,3 +122,4 @@ the mechanics: numbering, status, and the index. | [0058](0058-public-generator-facing-runtime-infrastructure.md) | Public Generator-Facing Runtime Infrastructure | Accepted | | [0059](0059-compono-nunit-package-design.md) | Compono.NUnit Package Design | Accepted | | [0060](0060-testdoubles-received-calls-and-clear-calls.md) | Compono.TestDoubles: `ReceivedCalls()` Retrospective Inspection and `ClearCalls()` | Accepted | +| [0061](0061-compono-options-testing-support.md) | Compono.Options: First-Class .NET Configuration/Options Testing Support | Accepted | diff --git a/docs/architecture/capability-admission.md b/docs/architecture/capability-admission.md new file mode 100644 index 00000000..76e6c218 --- /dev/null +++ b/docs/architecture/capability-admission.md @@ -0,0 +1,507 @@ +# Capability & Package Admission + +**Audience:** anyone asking *"should Compono support X?"* — a contributor +proposing a new capability, a maintainer triaging a feature request, or an +agent evaluating a candidate package before starting a design pass. + +**What this page is:** the current, standalone, operational description of +how Compono decides whether a proposed capability, feature, integration, or +package gets admitted. Read this page alone to run the process — you do not +need to read any ADR first. + +**What this page is not:** a history of *why* Compono's admission policy +looks the way it does. That rationale — the alternatives considered, the +research behind each threshold, the real candidates evaluated against +it — lives in [ADR-0029](../adr/0029-milestone-7-dogfooding-strategy-and-capability-gap-decision-framework.md) +and [ADR-0039](../adr/0039-future-extension-package-admission-gate-and-release-sequence.md) +(see **Provenance** at the bottom). This page is kept in sync with those +ADRs as the current process; if a future ADR changes the policy, this page +must be updated in the same PR — the same "update docs alongside the +decision that changed them" rule this repo already applies to every other +subsystem doc (see [Contributing](../contributing.md)). + +## When this process applies + +Run this process for: + +- a genuinely new capability (Compono does something it doesn't do today) +- a material expansion of an existing package's public surface +- a new extension/integration package + +Do **not** run this process for: + +- a bug fix restoring behavior an `Accepted` ADR or existing documentation + already promised +- straightforward implementation work against an already-`Accepted` ADR +- a small, mechanical, additive change with no new design decision (a + cookbook recipe, a test, a docs fix) + +This process is a gate on new *design* decisions, not ceremony for every +change — see [`contributing.md`](../contributing.md)'s "before you start" +section for the same boundary stated from a contributor's side ("anything +beyond a small fix... open a Feature Request issue first"). + +## The two-stage model + +Every candidate passes through two independent gates, in order. They +answer different questions and neither substitutes for the other: + +| Gate | Question | Answered by | +|---|---|---| +| **Gate A — Architectural admission** | Could this legitimately be part of Compono at all? | This page, applied once per candidate | +| **Gate B — Evidence admission** | Is there real reason to build it *now*? | Real demand: dogfooding friction, a repeated consumer request, or an explicit product-owner request | + +A candidate that fails Gate A is rejected (or downgraded to a +documentation-only idea) regardless of how much demand exists for it — Gate +A is not a formality an eager candidate can outweigh with enthusiasm. A +candidate that clears Gate A but has no Gate B evidence yet is a real, +recorded idea, not yet worth building. + +### Terminology + +These four terms have precise, distinct meanings — use them, not looser +synonyms, when discussing where a candidate stands: + +1. **Candidate** — proposed, not yet evaluated against Gate A. +2. **Admitted candidate** — cleared Gate A: architecturally legitimate, + still no evidence. Recorded in + [`docs/roadmap/future-packages.md`](../roadmap/future-packages.md), not + as its own `Proposed` ADR. +3. **Roadmap item** — cleared Gate B too: real evidence exists. Gets its + own problem-focused `Proposed` ADR, listed in + [`docs/roadmap/post-mvp.md`](../roadmap/post-mvp.md). +4. **Committed implementation work** — the roadmap item's ADR reaches + `Accepted` (its own full design pass, not just the problem statement) + and a Plan moves `In Progress` against it. + +"Not a package" and "not a good idea" are different verdicts — a proposal +can fail as a new *package* and still be admitted as core behavior, an +addition to an existing package, or a documentation recipe. Don't conflate +"this capability should exist" with "this deserves a new NuGet package"; +Gate A's last criterion below exists specifically to keep those questions +separate. + +## Step-by-step: running the process + +### Step 1 — State the concrete problem + +Require a real consumer/testing/composition problem, described concretely +— not "this would be nice" or "AutoFixture has this." Compono's explicit +non-goal is reproducing AutoFixture feature-for-feature +([design-principles.md](design-principles.md)); the existence of a feature +somewhere else is not itself a reason to add it here. If you can't state a +concrete scenario where a real test author is blocked or meaningfully +worse off without this, stop — there's no problem yet to evaluate against +the gates below. + +### Step 2 — Check how the problem is solved today + +Before treating this as a gap, check whether it's already solved by: + +- Compono core +- an existing Compono extension package +- ordinary .NET APIs +- first-party Microsoft testing abstractions +- an established ecosystem library +- a few lines of ordinary consumer code +- documentation/sample guidance (a Cookbook recipe, not a code change) + +The existence of boilerplate alone does not justify a new abstraction — +Gate A's "meaningful abstraction" criterion (Step 4) asks specifically +whether a consumer could already write this themselves in an afternoon. +`Compono.FakeItEasy` failed admission for exactly this reason: FakeItEasy's +`Sdk.Create.Fake(Type)` is a real extension point, but the resulting +package would be ~80% structurally identical to the already-shipped +`Compono.NSubstitute` — the "gap" was already closed by an existing +package wearing a different ecosystem's name. + +**"The .NET API is short" answers a narrower question than "this is +already solved."** Checking "is this already solved" means checking +whether the consumer gets a natural, discoverable, composition-native +answer *without leaving Compono's composition model* — not just whether +the underlying framework call is short. A one-line `Options.Create(...)` +or `new ConfigurationBuilder()...Build()` can still leave a real gap if a +consumer has to repeatedly reconstruct integration ceremony by hand, keep +several related registrations consistent with each other, or rediscover +the same pattern from memory every time — that's friction in the +composition workflow, not in the construction call, and this step must +weigh both. Two real, `Accepted` precedents establish that Compono already +treats this as legitimate: `CompositionBuilder.Share()` +([ADR-0056](../adr/0056-composition-builder-share-graph-wide-sharing.md)) +was admitted even though `[Shared]` already made sharing fully possible, +because expressing it as graph-wide, profile-reusable composition +configuration — rather than an attribute a consumer must remember to +attach to every relevant test signature — was itself real product value; +`Compono.Bogus` ([ADR-0027](../adr/0027-compono-bogus-package-design.md)) +was admitted even though a consumer could always hand-write a +plausible-looking fake value, because making that behavior discoverable +and natural inside the composition model (`UseBogus()`) was the actual +value, not raw difficulty. **Neither direction of this is a license to +loosen the bar**: "the framework already lets you do this" does not, by +itself, prove the capability is already well-solved *inside Compono's +model* — but "I sometimes forget the exact syntax" does not, by itself, +justify a Compono abstraction either. A convenience that leaves the +consumer no more correct, expressive, reusable, or composition-native than +before is still a trivial wrapper, and still fails Step 3 below — a named +method wrapping one `Register()` call for an already-simple type is +exactly this trap, not an example of legitimate ergonomic value. + +### Step 3 — Check Gate A: architectural admission + +A candidate must clear **all five** of the following — not just one — to +become an admitted candidate. These are evaluated once per candidate, and +none of them requires evidence of demand yet (that's Gate B): + +1. **Compono-specific value.** It solves meaningful composition-related + friction, not branding or convenience around an already-easy call. A + package that exists only because its underlying library is popular + fails here, even if it can technically claim to "supply composed + values." This friction is not only "the operation is hard to perform" + — real composition ergonomics count too, when the improvement is + genuinely about expressing intent inside Compono's composition model + rather than merely shortening syntax. Evidence worth weighing here + (none of it automatic — each still has to show up as a real, sourced + finding, not an assumption) includes: expressing intent at the + composition level instead of leaking it into individual tests or + production types; keeping consumers inside the composition model + instead of forcing them to reconstruct framework-specific ceremony + repeatedly; making behavior naturally reusable through profiles; + making related dependency shapes coherent instead of independently + hand-wired (and thus prone to silently drifting inconsistent); + establishing consistent semantics consumers would otherwise repeatedly + reinvent, each slightly differently; and making the obvious, + discoverable Compono path also the correct one. `Share()` and + `Compono.Bogus` (Step 2, above) both cleared this criterion on exactly + this kind of evidence, not on raw difficulty. +2. **Native ecosystem fit.** The resulting API is idiomatic in the + integrated ecosystem's own terms — not a clone of an existing Compono + integration's shape bolted onto a different framework's extension + model. (`Compono.NUnit`'s `IParameterDataSource` gives genuine + per-parameter granularity `Compono.XunitV3`'s row model doesn't have — + a real, distinct shape, not a re-skin.) +3. **Meaningful abstraction.** Consumers get materially more than a + trivial extension method they could write themselves in an afternoon. + Judge this against the *whole* consumer composition workflow, not just + the one construction expression — remembering which framework API/ + package is involved, wrapping and registering it correctly, keeping + several related registrations consistent with each other, and making + the result reusable, can add up to real friction even when any single + line of it looks trivial in isolation. A candidate clears this + criterion by collapsing that workflow into a coherent composition + concept, not merely by giving an existing one-liner a Compono-branded + name — a helper that leaves the consumer no more correct, expressive, + reusable, or composition-native than before still fails here, no + matter how the friction is described. +4. **Architectural fit.** It can be built entirely on an existing public + extension point, or on a to-be-designed extension point named + explicitly as a prerequisite — never on a core change invented ad hoc + during the candidate's own design pass. (`Compono`'s core package must + never reference or know about an integration package — see + [design-principles.md](design-principles.md)'s "Modular architecture." + If satisfying the candidate requires reflection or hidden state, that + conflicts with [ADR-0001](../adr/0001-source-generation-first.md)'s + no-reflection-by-default posture and needs a much higher bar to survive + as an intentional exception rather than a workaround.) +5. **Package-boundary justification.** *If* this is proposed as a new + package: the dependency genuinely belongs outside core, and is + substantial enough to justify another independently-consumed artifact + rather than a documentation recipe or an addition to an existing + package. This is a separate question from "should this capability + exist" — see Step 6. + +Maintenance/CI/docs/skill-maintenance cost (an additional entry in the +`compono` agent skill's detection table, another package guide, another +CI package-validation target) is a real **weighing factor** across all +five criteria above, not a standalone sixth pass/fail condition — it's +linear and small per additional package for this repo's existing routing +pattern, and shouldn't by itself veto a candidate that otherwise clears +the five bars. + +A candidate that fails Gate A does not get a `Proposed` ADR of its own, +regardless of demand. It's either rejected outright, or — where a Gate A +finding surfaces a genuine, narrower recipe worth writing down — +downgraded to a **documentation-only idea** recorded in +`future-packages.md` (see Outcomes, below). + +### Step 4 — Check Gate B: evidence admission + +A candidate that clears Gate A is an admitted candidate — architecturally +legitimate, but still just an idea until real evidence justifies building +it now. Evidence can come from more than one source; dogfooding is strong +evidence but is **not** the only accepted trigger: + +- **Dogfooding friction** — a real migration or real project surfaces + repeated, concrete friction. This is the strongest form of evidence + because it's falsifiable: a spike built to exercise a hypothesis is + structurally likely to "prove" it matters even when real usage wouldn't; + a real call site that already existed before Compono was involved is + not. +- **A repeated, concrete consumer request** naming a specific scenario. +- **An explicit product-owner request** — this alone has satisfied Gate B + for real, shipped packages (`Compono.TUnit`, `Compono.NUnit`, and + Compono-owned source-generated test doubles all cleared Gate B this way, + with no dogfooding evidence at the time). A clear ask from whoever owns + the product direction is real evidence, not a fallback used only when + dogfooding hasn't happened yet. + +When evidence does come from a real migration, weigh it with the same four +questions every time — for the candidate gaps named below and any further +one a migration surfaces: + +1. **Observed frequency.** How many real, distinct places actually needed + this behavior — not "could plausibly use it," but did, in the code as + it stood. +2. **Was this scenario ever intended to work?** If Compono's documented or + `Accepted`-ADR behavior already claims to support the scenario and it + doesn't, that's a **bug**, not a design question — fix it through the + normal engineering workflow (`tasks/implement.md`/`tasks/pr-review.md`), + not this process. Skip the remaining questions. +3. **Workaround cost.** Concretely, what does Compono's existing explicit + alternative cost — extra parameters, extra lines, an implementation + detail leaking into a test signature — shown as a real before/after, not + a hypothetical. A low or zero cost points toward "acceptable + alternative, no new capability needed"; a real, material cost points + toward a genuine capability gap. +4. **Principle alignment.** Would satisfying this gap require reflection + or hidden state conflicting with the no-reflection-by-default posture, + or with this project's explicit-over-implicit bias + ([design-principles.md](design-principles.md))? A gap that can only be + closed by working against an existing constraint needs a much higher + bar on frequency and cost before it becomes a genuine capability gap + rather than an intentional design difference. + +**Do not turn "it must be dogfooded" into an absolute prerequisite** — an +admitted candidate with no dogfooding history can still clear Gate B on an +explicit, well-reasoned product-owner request. Conversely, don't let "a +consumer might want this someday" stand in for real evidence either — a +plausible-sounding future want is not evidence under any of the three +triggers above. + +### Step 5 — Weigh the cost + +Evidence required should be proportional to the cost and permanence of what's +being proposed. Consider, relative to the candidate's actual scope: + +- public API surface added +- generator complexity, if any +- runtime machinery and allocations +- new dependencies +- ongoing maintenance burden +- documentation burden (a new Concept page, Package Guide, or Cookbook + entry) +- skill/eval burden (the `compono` agent skill's per-package reference + files) +- compatibility commitments and future design constraints this creates + +A one-line addition to an existing package's public surface needs far less +evidence than a new package with its own release cadence and support +surface. + +### Step 6 — If admitted, decide where it belongs + +Passing Gate A and Gate B means the capability should exist — it does not +by itself mean it needs a new package. Work down this list and stop at the +smallest home that's honestly justified: + +1. **Core `Compono`.** Only if it doesn't depend on any test framework or + test-double/data library — core must never reference or know about an + integration package. +2. **An existing extension package.** If the capability's dependency + already matches an existing package's ecosystem (e.g. something + NSubstitute-specific belongs in `Compono.NSubstitute`, not a new + package). +3. **A new extension package.** Only when Gate A's package-boundary + criterion is genuinely satisfied — the dependency belongs outside core + *and* outside every existing package, and is substantial enough to + justify its own independently-consumed artifact. +4. **Documentation/sample guidance only.** When the capability is real and + worth recording, but doesn't need new code at all — a Cookbook recipe + showing how a consumer builds it themselves in a few lines. + +Two real precedents show this isn't a rubber stamp toward "new package": + +- **`Compono.DependencyInjection`** shipped, but as a narrower + configured-resolution `IServiceProvider` bridge + (`row.AsServiceProvider()`) than the "richer DI integration" idea + originally evaluated (keyed-service resolution, DI-scope ownership) — + that larger idea failed Gate A's architectural-fit criterion because it + needed a core concept that didn't exist yet, and remains a + documentation-only idea today, unrelated to the narrower thing that + actually shipped under the same name. +- **FakeItEasy** support was downgraded from a package candidate to a + documentation-only recipe ("how to write your own + `ICompositionValueProvider` for FakeItEasy," following + `Compono.NSubstitute`'s published shape) rather than becoming + `Compono.FakeItEasy` — the capability is real and worth documenting, but + didn't justify its own package. + +## Outcomes + +Every evaluated candidate ends in exactly one of these: + +| Outcome | Meaning | Where it's recorded | +|---|---|---| +| **Rejected** | Fails Gate A; no legitimate Compono capability here | Not recorded as a candidate; reopen only if new evidence changes the Gate A analysis | +| **Documentation-only** | Fails Gate A as a *package*, but the capability is worth a recipe/guide | `docs/roadmap/future-packages.md`, "Documentation-only ideas" | +| **Deferred** | Clears Gate A, but blocked on an external factor (e.g. a dependency's maintenance health) | `docs/roadmap/future-packages.md`, "Deferred indefinitely," with an explicit re-evaluation trigger | +| **Admitted candidate** | Clears Gate A; no Gate B evidence yet | `docs/roadmap/future-packages.md`, "Admitted candidates" | +| **Roadmap item** | Clears Gate A and Gate B | Problem-only `Proposed` ADR, listed in `docs/roadmap/post-mvp.md` | +| **Committed implementation work** | Roadmap item's ADR reaches `Accepted`, Plan moves `In Progress` | The ADR + its Plan, per the normal design/implement workflow | + +A finding from a real migration that isn't a genuine capability gap at all +still gets classified and recorded (per ADR-0029), even though it never +enters this table as a candidate: + +- **Acceptable Compono-native alternative** — a different API than the + thing being compared against, but the replacement stays pleasant (low + workaround cost, no material readability loss). Documented as a pattern + in the relevant guide; no ADR or Amendment needed. +- **Intentional design difference** — the alternative would conflict with + Compono's principles, or costs more than its observed value justifies. A + dated Amendment to the ADR that governs the existing behavior records + the evidence and the "no change" verdict — this is a real, indexed "no," + not a dropped finding. +- **Migration-only friction** — pain during a one-time conversion that + doesn't persist in the resulting test suite. Recorded as a tip for the + next migrator; no ADR or Amendment needed. + +## Decision flow + +``` +Proposed capability / package + | + v +Step 1: Is there a real, concrete problem? + | no -> stop, not a candidate + v yes +Step 2: Is an existing solution already good enough? + | yes -> Documentation-only (a recipe, not a package) + v no +Step 3: Gate A — architecturally legitimate? + (Compono-specific value, native ecosystem fit, meaningful + abstraction, architectural fit, package-boundary justification) + | fails -> Rejected, or Documentation-only if a narrower + | recipe genuinely survives + v clears + Admitted candidate + | + v +Step 4: Gate B — real evidence now? + (dogfooding friction / repeated consumer request / + explicit product-owner request) + | no evidence yet -> stays Admitted candidate + | external blocker -> Deferred (with re-evaluation trigger) + v evidence exists + Roadmap item -> Proposed ADR + | + v + ADR reaches Accepted, Plan In Progress + | + v + Committed implementation work + | + v +Step 6: Where does it live? + Core / Existing package / New package / Docs-only +``` + +## Worked examples + +Real Compono history, not hypotheticals: + +- **`Compono.TUnit` (admitted, shipped).** Cleared Gate A on a real, + distinct integration surface (TUnit's `IDataSourceAttribute` family, + per-row `TestBuilderContext`) — not because TUnit is source-generated + like Compono itself; that original rationale didn't survive scrutiny and + was explicitly retired. Cleared Gate B via an explicit product-owner + request, not dogfooding. Reached committed implementation work and + shipped. +- **`Compono.Http` (admitted, shipped).** Cleared Gate B through real + dogfooding evidence: a real consumer project's hand-rolled, + reflection-based `HttpMessageHandler` fake, used across 41 real call + sites, with duplicated fake-handler classes solving the same problem + three different ways. The friction was real, repeated, and worse than + every surveyed alternative — a strong Gate B case. +- **`Compono.FakeItEasy` (rejected as a package, documentation-only).** + Real extension point, but ~80% structurally identical to + `Compono.NSubstitute` — failed "meaningful abstraction" relative to a + package that already exists, not because FakeItEasy's own API is thin. +- **`CompositionBuilder.Share()` (admitted, shipped — composition + ergonomics, not raw difficulty).** `[Shared]` already made sharing a + value across a composition graph fully possible before this shipped — + nothing was technically blocked. Admitted anyway because expressing + sharing as graph-wide, profile-reusable composition configuration, + instead of an attribute a consumer has to remember to attach to every + relevant test signature, was itself real Compono-specific value (Step 2/ + Gate A criterion 1, above). +- **`Compono.Bogus` (admitted, shipped — composition ergonomics, not raw + difficulty).** A consumer could always hand-write a plausible-looking + fake value; nothing about that is hard. Admitted because making + realistic-looking values discoverable and natural inside the + composition model (`UseBogus()`) was the actual value. +- **`Compono.Moq` (deferred).** A workable integration surface exists, but + Moq had shipped no release in roughly 23 months and carries + reputational damage from a past incident — deferred with an explicit + re-evaluation trigger (Moq resumes active releases), not silently + dropped. +- **NSubstitute's `ConfigureMembers` (intentional design difference).** + Real dogfooding surfaced a case where AutoFixture's + `AutoNSubstituteCustomization { ConfigureMembers = true }` + auto-configures every generated substitute's members recursively. + `Compono.NSubstitute` deliberately doesn't — that gap was weighed + through the Gate B rubric and, if the evidence supports it, recorded as + a dated Amendment to the ADR governing that decision rather than + becoming a new roadmap item. (Illustrative of the *mechanism*; check + that ADR's own Amendments for the actual, current verdict rather than + treating this summary as the record.) + +## When this page is not enough + +This page is the operational summary. For the full reasoning behind a +threshold, a rejected alternative, or a specific candidate's disposition, +follow the links into the ADRs below — you shouldn't need to, but they're +the permanent record if you want it. + +## Provenance + +This page consolidates the current admission policy from: + +- [ADR-0029](../adr/0029-milestone-7-dogfooding-strategy-and-capability-gap-decision-framework.md) + — the evidence rubric (Gate B), the four evidence questions, the + five-way finding classification (bug / roadmap candidate / acceptable + alternative / intentional design difference / migration-only friction), + and the bug-handling carve-out. +- [ADR-0039](../adr/0039-future-extension-package-admission-gate-and-release-sequence.md) + and its Amendment 1 — the two-stage model, Gate A's five criteria, the + candidate/admitted-candidate/roadmap-item/committed-implementation-work + terminology, and the explicit rejection of a committed release sequence. +- Real Gate A/Gate B applications recorded in + [ADR-0040](../adr/0040-compono-tunit-package-design.md) (`Compono.TUnit`), + [ADR-0042](../adr/0042-compono-owned-source-generated-test-doubles.md) + (Compono-owned test doubles), + [ADR-0051](../adr/0051-compono-http-handler-based-testing-package.md) + (`Compono.Http`), and + [ADR-0059](../adr/0059-compono-nunit-package-design.md) (`Compono.NUnit`) + — the worked examples above are drawn from these. +- [`docs/roadmap/future-packages.md`](../roadmap/future-packages.md) and + [`docs/roadmap/post-mvp.md`](../roadmap/post-mvp.md) — the live, + current status of every candidate this process has ever evaluated. +- [ADR-0056](../adr/0056-composition-builder-share-graph-wide-sharing.md) + and [ADR-0027](../adr/0027-compono-bogus-package-design.md) — the + `Share()`/`Compono.Bogus` precedent establishing that composition + ergonomics (Step 2, Gate A criterion 1 above) count as legitimate + Compono-specific value independent of raw operation difficulty. +- [`docs/research/0028-compono-options-configuration-admission-research.md`](../research/0028-compono-options-configuration-admission-research.md) + — the investigation whose reassessment surfaced that this page could be + read too narrowly (raw API simplicity treated as sufficient reason to + stop at Step 2) and fed the composition-ergonomics clarifications above + back into this page. + +The ADRs above remain authoritative for historical rationale and the +architectural decision history — the alternatives considered, why they +were rejected, and the research behind each threshold. **This page is the +canonical current operational description of the admission process.** +When a future ADR changes the policy (a new Gate A criterion, a revised +evidence bar, a retired outcome category), update this page in the same +PR — an admission process that only lives correctly in an ADR's prose +recreates exactly the discoverability problem this page exists to solve. diff --git a/docs/architecture/index.md b/docs/architecture/index.md index e5f02aec..19a673f1 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -15,6 +15,10 @@ underlying decision rather than re-deriving the reasoning. - **[Design Principles](design-principles.md)** — current, evolving: what Compono believes (composition over object generation, predictability over magic, source-generated by default, deterministic by design). +- **[Capability & Package Admission](capability-admission.md)** — the + current, standalone process for deciding whether a proposed capability, + feature, integration, or package gets admitted into Compono. Read this + before proposing new scope, not the ADRs it's consolidated from. - **Current Architecture** — how it works today: [Source Generation](current/source-generation.md), [Generated Plans and Discovery](current/generated-plans-and-discovery.md), diff --git a/docs/contributing.md b/docs/contributing.md index bde0642a..88ffaa48 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -22,7 +22,12 @@ instead of opening a public issue. issue first and let a maintainer weigh in before you invest in an implementation — this repo intentionally has one way to do each thing, and a design conversation up front is cheaper than a large PR that has - to change direction in review. + to change direction in review. If what you're proposing is a new + capability or package rather than a fix, run it through + [Capability & Package Admission](architecture/capability-admission.md) + first — it's the standalone process Compono uses to decide whether + something like this belongs in the project at all, before any design + work starts. - Looking for a first contribution? A missing [Cookbook](cookbook/index.md) recipe is the easiest way in — narrow in scope, easy to review, and doesn't require touching the composition engine itself. See the diff --git a/docs/cookbook/compose-configuration-from-an-in-memory-collection.md b/docs/cookbook/compose-configuration-from-an-in-memory-collection.md new file mode 100644 index 00000000..0ea40372 --- /dev/null +++ b/docs/cookbook/compose-configuration-from-an-in-memory-collection.md @@ -0,0 +1,84 @@ +--- +title: Compose Configuration From an In-Memory Collection +description: Register a real IConfiguration for a test, built from plain in-memory key/value pairs. +packages: [Compono] +concepts: [registration] +--- + +# Compose Configuration From an In-Memory Collection + +## Problem + +Your code under test depends on `IConfiguration` directly (`GetSection`, +`GetValue`, configuration-binding helpers) and the test wants a real, +predictable `IConfiguration` instance — not a hand-rolled fake, and no +external config file on disk. + +## Solution + +```csharp +using Microsoft.Extensions.Configuration; + +IConfiguration BuildConfiguration() => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Database:ConnectionString"] = "test-connection-string", + ["Database:TimeoutSeconds"] = "5", + }) + .Build(); +``` + +```csharp +public sealed class DatabaseConfigurationProfile : ICompositionProfile +{ + public void Configure(CompositionBuilder builder) => + builder.Register(BuildConfiguration); +} +``` + +```csharp +[Theory] +[Compose] +public void ReadsTheConnectionStringFromConfiguration(IConfiguration configuration) +{ + configuration["Database:ConnectionString"].Should().Be("test-connection-string"); +} +``` + +Or, wired directly through a hand-built `Composer` without a test-framework +attribute at all: + +```csharp +var composer = Composer.Create(builder => builder + .Register(BuildConfiguration)); + +var repository = composer.Create(); +``` + +## Discussion + +This is ordinary `Microsoft.Extensions.Configuration` composition — +`Compono` adds nothing beyond the standard `Register` call. +`AddInMemoryCollection` accepts colon-separated keys (`"Database:TimeoutSeconds"`) +the same way a real `appsettings.json`'s nested sections flatten when +bound, so `configuration.GetSection("Database").GetValue("TimeoutSeconds")` +and configuration-binding (`configuration.GetSection("Database").Get()`) +both work exactly as they would against a real configuration source. + +There is no `Compono.Configuration` package — plain +`ConfigurationBuilder`/`Register` composition, as above, is +the whole answer for `IConfiguration` itself. If your code under test +instead depends on `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` +for a strongly-typed settings class, that's +[`Compono.Options`](../packages/compono-options.md)'s job, not this +recipe's — the two compose independently and don't need to agree with each +other's values. + +## See also + +- [Layer Configuration Overrides in a Test](layer-configuration-overrides-in-a-test.md) +- [Reuse Configuration Through a Profile](reuse-configuration-through-a-profile.md) +- [`Compono.Options` Package Guide](../packages/compono-options.md) — for + `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` instead of plain + `IConfiguration`. diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md index bbf9fa05..6fd33751 100644 --- a/docs/cookbook/index.md +++ b/docs/cookbook/index.md @@ -16,9 +16,12 @@ made once the list is actually large enough to need it, not guessed at now. ## Recipes - [Compose a Substitute With One Method Stubbed](compose-a-substitute-with-one-method-stubbed.md) +- [Compose Configuration From an In-Memory Collection](compose-configuration-from-an-in-memory-collection.md) - [Freeze a Shared HttpMessageHandler](freeze-a-shared-httpmessagehandler.md) - [Generate a Realistic Email](generate-a-realistic-email.md) +- [Layer Configuration Overrides in a Test](layer-configuration-overrides-in-a-test.md) - [Override One Field Only for One Test](override-one-field-only-for-one-test.md) +- [Reuse Configuration Through a Profile](reuse-configuration-through-a-profile.md) - [Seed a Specific Failing Case for Reproduction](seed-a-specific-failing-case-for-reproduction.md) ## Next diff --git a/docs/cookbook/layer-configuration-overrides-in-a-test.md b/docs/cookbook/layer-configuration-overrides-in-a-test.md new file mode 100644 index 00000000..740dd1bb --- /dev/null +++ b/docs/cookbook/layer-configuration-overrides-in-a-test.md @@ -0,0 +1,66 @@ +--- +title: Layer Configuration Overrides in a Test +description: Start from a shared baseline configuration and override just the values one test cares about. +packages: [Compono] +concepts: [registration] +--- + +# Layer Configuration Overrides in a Test + +## Problem + +Most tests should share one baseline configuration, but a specific test +needs one or two values different — without duplicating every other key +just to change one. + +## Solution + +```csharp +using Microsoft.Extensions.Configuration; + +IConfiguration BuildConfiguration(IDictionary? overrides = null) +{ + var builder = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Retry:MaxAttempts"] = "3", + ["Retry:DelayMilliseconds"] = "100", + }); + + if (overrides is not null) + { + builder.AddInMemoryCollection(overrides); + } + + return builder.Build(); +} +``` + +```csharp +var composer = Composer.Create(builder => builder + .Register(() => BuildConfiguration( + new Dictionary { ["Retry:MaxAttempts"] = "0" }))); + +var client = composer.Create(); +// client observes Retry:MaxAttempts = "0", Retry:DelayMilliseconds = "100" (unchanged baseline) +``` + +## Discussion + +`AddInMemoryCollection`, like every `IConfigurationSource`, follows +`Microsoft.Extensions.Configuration`'s own later-source-wins layering — a +later `AddInMemoryCollection` call overrides a key an earlier one already +set, and leaves every key it doesn't mention untouched. This is real +`IConfiguration` behavior, not a Compono-specific mechanism; the same +pattern applies to `AddJsonFile`/`AddEnvironmentVariables`/any other real +source if a project's test setup needs them. + +Keep the override dictionary scoped to exactly the keys one test needs to +differ — resist the temptation to duplicate the whole baseline "just to be +safe." A test that overrides one key should be legible as "the same +configuration as everywhere else, except this one thing." + +## See also + +- [Compose Configuration From an In-Memory Collection](compose-configuration-from-an-in-memory-collection.md) +- [Reuse Configuration Through a Profile](reuse-configuration-through-a-profile.md) diff --git a/docs/cookbook/reuse-configuration-through-a-profile.md b/docs/cookbook/reuse-configuration-through-a-profile.md new file mode 100644 index 00000000..5521bf09 --- /dev/null +++ b/docs/cookbook/reuse-configuration-through-a-profile.md @@ -0,0 +1,90 @@ +--- +title: Reuse Configuration Through a Profile +description: Establish one project's baseline IConfiguration once, in a shared ICompositionProfile. +packages: [Compono] +concepts: [profiles, registration] +--- + +# Reuse Configuration Through a Profile + +## Problem + +Every test in a project needs the same baseline `IConfiguration` — copying +the same `ConfigurationBuilder`/`AddInMemoryCollection` call into every +test class is repetitive and drifts inconsistent over time. + +## Solution + +```csharp +using Microsoft.Extensions.Configuration; + +public sealed class AppConfigurationProfile : ICompositionProfile +{ + public void Configure(CompositionBuilder builder) => builder + .Register(() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Retry:MaxAttempts"] = "3", + ["Retry:DelayMilliseconds"] = "100", + }) + .Build()); +} +``` + +```csharp +[Theory] +[Compose] +public void ReadsTheSharedBaselineConfiguration(IConfiguration configuration) +{ + configuration["Retry:MaxAttempts"].Should().Be("3"); +} +``` + +Or applied to a hand-built `Composer` directly: + +```csharp +var composer = Composer.Create(builder => builder.AddProfile()); +``` + +## Discussion + +`ICompositionProfile.Configure` is just an ordinary sequence of +`CompositionBuilder` calls — `Register` needs no special +profile-only mechanism to be reusable this way, the same as any other +registration. + +Registering the same exact type more than once is a build-time conflict in +Compono, not last-write-wins or first-write-wins (`docs/adr/0019-registrations-and-service-provider-injection.md`) — +so a test can't compose `AppConfigurationProfile` (which already calls +`Register`) and *then* call `Register` +again itself to layer an override; that collides and throws +`CompositionConfigurationException`. A test that needs the shared baseline +plus one local override instead passes the override into the profile's own +constructor and applies it with the instance-based +`AddProfile(ICompositionProfile)` overload, so the profile itself is the +one and only place that calls `Register`: + +```csharp +public sealed class AppConfigurationProfile(IDictionary? overrides = null) : ICompositionProfile +{ + public void Configure(CompositionBuilder builder) => builder + .Register(() => BuildConfiguration(overrides)); +} +``` + +```csharp +var composer = Composer.Create(builder => builder.AddProfile( + new AppConfigurationProfile(new Dictionary { ["Retry:MaxAttempts"] = "0" }))); +``` + +See [Layer Configuration Overrides in a Test](layer-configuration-overrides-in-a-test.md) +for `BuildConfiguration`'s own layering (multiple `AddInMemoryCollection` +calls inside one `ConfigurationBuilder`, not multiple `Register` calls), +and [Composition Profiles](../concepts/profiles.md) for the full profile +mechanics. + +## See also + +- [Compose Configuration From an In-Memory Collection](compose-configuration-from-an-in-memory-collection.md) +- [Layer Configuration Overrides in a Test](layer-configuration-overrides-in-a-test.md) +- [Composition Profiles](../concepts/profiles.md) diff --git a/docs/index.md b/docs/index.md index 0526f10a..7f010a22 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,6 +47,7 @@ Compono determines **how** those requirements are satisfied. | `Compono.Logging` | `Microsoft.Extensions.Logging` testing support (`UseLogging()`, `CapturingLogger`) | | `Compono.MSTest` | MSTest integration | | `Compono.NUnit` | NUnit integration (no `[TestFixture]` required) | +| `Compono.Options` | `Microsoft.Extensions.Options` testing support (`TestOptionsSource`, `UseOptions()`) | ## Example diff --git a/docs/packages/compono-options.md b/docs/packages/compono-options.md new file mode 100644 index 00000000..2f44b719 --- /dev/null +++ b/docs/packages/compono-options.md @@ -0,0 +1,193 @@ +# Compono.Options + +First-class `Microsoft.Extensions.Options` testing support — +`TestOptionsSource`, a hand-written, reflection-free source of truth +that coherently backs `IOptions`/`IOptionsSnapshot`/ +`IOptionsMonitor` for one settings type from one test-configured +instance, wired via a single `UseOptions()` composition call. See +[ADR-0061](../adr/0061-compono-options-testing-support.md) for the full +decision record and +[RESEARCH-0028](../research/0028-compono-options-configuration-admission-research.md) +for the admission investigation this package's shape came from. + +## When to install + +Your code under test depends on `IOptions`, `IOptionsSnapshot`, or +`IOptionsMonitor` for a settings type, and the test wants one coherent, +correct source of truth for all of them — not three separately hand-wired +registrations that can silently drift inconsistent, and not a hand-rolled +`IOptionsMonitor` fake (the community's own standard answer for this is +[demonstrably buggy](https://benfoster.io/blog/20200610-testing-ioptionsmonitor/): +named options silently broken, only one `OnChange` subscriber ever +honored, a no-op `IDisposable`): + +```bash +dotnet add package Compono +dotnet add package Compono.Options +``` + +If your code under test only reads plain `IConfiguration` (no `Options` +interfaces at all), this package isn't what you want — see the +[Configuration Cookbook](../cookbook/compose-configuration-from-an-in-memory-collection.md) instead. There is +no `Compono.Configuration` package; ordinary `ConfigurationBuilder` +composition already covers that case with no dedicated package needed. + +## What it gives you + +```csharp +using Compono.Options; + +var source = new TestOptionsSource( + new EmailServiceConfiguration { ApiKey = "test-key" }); + +var composer = Composer.Create(builder => builder.UseOptions(source)); + +var service = composer.Create(); +``` + +- **`TestOptionsSource`** — the one type you construct directly per + settings type. Its constructor establishes the default value + immediately — there's no separate setup call before your test can + resolve `IOptions`/`IOptionsMonitor.CurrentValue`. It implements + `IOptionsMonitor` itself. +- **`CompositionBuilder.UseOptions(source)`** — the one wiring call. + Coherently satisfies all three Microsoft interfaces from `source`: + - **`IOptions`** — a fresh, frozen view captured once and shared for + the whole composition graph. Never changes after that first + resolution, matching real `IOptions`'s actual "computed once" + contract. + - **`IOptionsMonitor`** — `source` itself. `CurrentValue`/`Get(name)` + always reflect `source`'s current state; `OnChange(...)` subscribes to + live updates. + - **`IOptionsSnapshot`** — a fresh frozen view captured from + `source`'s *current* state on every resolution. One resolved instance + stays stable even if `source` changes afterward; a *later* resolution + sees whatever's current at that later moment. +- **`.Change(value)`/`.Change(name, value)`** — the one mutation surface on + `source`. Updates the stored value, then synchronously notifies every + current `OnChange` subscriber — the new value is visible from inside the + callback itself. Also how a named value is *first* established; there's + no separate "add" API. +- **`OnChange(Action listener)`** returns a real, per-subscription + `IDisposable` — disposing it actually unsubscribes (`-=` against the + exact delegate), unlike the community fake's no-op. No per-subscriber + exception isolation, matching real `OptionsMonitor` exactly — a + throwing subscriber blocks subsequent ones in that same invocation. + +## Named options + +```csharp +source.Change("secondary", new EmailServiceConfiguration { ApiKey = "secondary-key" }); + +monitor.Get("secondary"); // EmailServiceConfiguration { ApiKey = "secondary-key" } +monitor.Get("never-set"); // throws UnconfiguredNamedOptionException +``` + +Name comparison is **case-sensitive** (ordinal), matching the real +contract. `IOptions` has no named-lookup surface at all — only +`IOptionsMonitor.Get(name)` and `IOptionsSnapshot.Get(name)` accept a +name. + +## Unconfigured named options — an intentional divergence + +Real `IOptionsFactory.Create(name)` silently returns `new TOptions()` +for a name with no matching configuration — no exception, no signal +anything was unconfigured. `Compono.Options` diverges deliberately: +`Get(name)` for a name never established via `.Change(name, value)` throws +`UnconfiguredNamedOptionException`, naming the settings type and the +requested name. This is the same explicit-configuration-over-silent-default +tradeoff `Compono.TestDoubles` already made for configuration-required +members ([ADR-0045](../adr/0045-testdoubles-configuration-required-members.md)) — +a test failure that names exactly what's missing, rather than a silently +wrong default settings object reaching your code under test. + +## Inline and profile usage + +`UseOptions` is an ordinary `CompositionBuilder` call — it reads +identically inline or inside an `ICompositionProfile.Configure`: + +```csharp +public sealed class EmailServiceTestProfile : ICompositionProfile +{ + public void Configure(CompositionBuilder builder) => + builder.UseOptions(new TestOptionsSource( + new EmailServiceConfiguration { ApiKey = "test-key" })); +} +``` + +A real, before/after worked example from `alexa-vox-craft`'s +`MediatRTestProfile.cs`, this package's dogfooding validation target +(ADR-0061's "Dogfooding validation" section): + +**Before** (the real shape `alexa-vox-craft`'s `MediatRTestProfile.cs` had +before adopting this package — two separately-maintained registrations for +one settings type, nothing structurally enforcing they agree): + +```csharp +builder + .Register(_ => new SkillServiceConfiguration + { + SkillId = "amzn1.ask.skill.default-test-id", + CustomUserAgent = "TestAgent/1.0", + }) + .Register>(context => + Options.Create(context.Resolve())); +``` + +**After** — one shared instance, one `UseOptions` wiring call for the +`IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` side; the plain +`SkillServiceConfiguration` registration stays (a separate consumer in this +same project needs the bare type, not an Options interface — `UseOptions` +only wires the three Options interfaces, by design), now sourced from the +identical instance rather than a second independently-constructed one: + +```csharp +var defaultSkillServiceConfiguration = new SkillServiceConfiguration +{ + SkillId = "amzn1.ask.skill.default-test-id", + CustomUserAgent = "TestAgent/1.0", +}; + +builder + .Register(() => defaultSkillServiceConfiguration) + .UseOptions(new TestOptionsSource(defaultSkillServiceConfiguration)); +``` + +Validated against the real `alexa-vox-craft` repository (dogfooding, in an +isolated worktree/branch, never committed against the real repo): the full +solution — 2564 tests across every project, not just `AlexaVoxCraft.MediatR.Tests` +— builds and passes against `Compono.Options` packed from this working +tree, restored as an ordinary `PackageReference` (no `ProjectReference` +bypass). + +## Disposal + +`TestOptionsSource` does **not** implement `IDisposable`/ +`IAsyncDisposable` — it owns no disposable resource (no file watcher, no +real `IChangeToken`, no DI scope). The `IDisposable` returned by +`OnChange(...)` is the only thing a test disposes, and only when it wants +to unregister that specific subscription. + +## What this package doesn't do + +- No real `IConfiguration`/change-token/file-watcher simulation, no + `IOptionsFactory` pipeline, no DI-scope/container simulation. +- No automatic, no-registration composition for an arbitrary settings + type `T` — a consumer always constructs `TestOptionsSource` and calls + `UseOptions` explicitly. A `T` reachable only through a nested + `context.Resolve()` call inside another factory isn't independently + discoverable as a composition root + ([ADR-0052](../adr/0052-compile-time-composition-discovery-boundary-for-registered-and-nested-resolved-types.md)'s + "Finding B") — this package doesn't solve that; it sidesteps it + entirely by having the test supply the value directly. +- No `Compono.Configuration` package — see the + [Configuration Cookbook](../cookbook/compose-configuration-from-an-in-memory-collection.md) for plain + `IConfiguration` composition. +- No `CallVerifier`-based verification of `Change`/`OnChange` call counts. + +## Next + +- [Configuration Cookbook](../cookbook/compose-configuration-from-an-in-memory-collection.md) — plain + `IConfiguration` composition, independent of this package. +- [ADR-0061](../adr/0061-compono-options-testing-support.md) — the full + decision record. diff --git a/docs/packages/index.md b/docs/packages/index.md index ee014124..009f0e03 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,6 +1,6 @@ # Package Guides -Compono ships as eleven independently-installable NuGet packages. Pick which +Compono ships as twelve independently-installable NuGet packages. Pick which ones you need before reading any single guide in depth — most projects only need the first two. @@ -17,6 +17,7 @@ need the first two. | [`Compono.DependencyInjection`](compono-dependencyinjection.md) | `row.AsServiceProvider()` — a configured-resolution `IServiceProvider` bridge over a `CompositionRow`. | You need Compono's registered/provider-backed values reachable through a plain `IServiceProvider`, e.g. as a fallback provider for another ecosystem's own DI container. | | [`Compono.Http`](compono-http.md) | `TestHttpHandler` — a reflection-free `HttpMessageHandler` test double: `OnGet`/`OnPost`/etc. + `When(...)` matching, strict unmatched-request behavior, registration-handle verification. | Your test needs to exercise the real `HttpClient` pipeline against a configured HTTP response, instead of substituting an application-level interface. | | [`Compono.Logging`](compono-logging.md) | `UseLogging()` — `ILogger`/`ILogger` compose as a hand-written `CapturingLogger`/`CapturingLogger`, with structured-property extraction, real scope tracking, and `Verify()` verification. Generation is on by default once installed. | Your composed type takes an `ILogger`/`ILogger` dependency and the test wants to assert what was logged. | +| [`Compono.Options`](compono-options.md) | `TestOptionsSource`/`UseOptions()` — one test-configured source of truth coherently backing `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` for a settings type. | Your composed type depends on `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` and the test wants one coherent source, not hand-wired separate registrations or a buggy hand-rolled Monitor fake. | Every package targets `net8.0`/`net9.0`/`net10.0`/`net11.0` and has a stable release — see [Installation](../getting-started/installation.md) @@ -55,7 +56,7 @@ whether or not you're also using xUnit v3 integration). ## Version compatibility -All eleven packages ship in lockstep during the `0.x` line — each integration +All twelve packages ship in lockstep during the `0.x` line — each integration package's dependency on `Compono` is exact-pinned at pack time, so mixing versions across packages (e.g. `Compono.XunitV3 0.3.0` with `Compono 0.5.0`) is not supported and will fail to restore. Always update all diff --git a/docs/plans/0064-compono-options-testing-support.md b/docs/plans/0064-compono-options-testing-support.md new file mode 100644 index 00000000..302eedef --- /dev/null +++ b/docs/plans/0064-compono-options-testing-support.md @@ -0,0 +1,655 @@ +# [PLAN-0064] Compono.Options: First-Class .NET Configuration/Options Testing Support + +**Status:** Done + +**Implements:** [ADR-0061](../adr/0061-compono-options-testing-support.md) + +## Goal + +A new `Compono.Options` package ships `TestOptionsSource` — a +hand-written, reflection-free, non-generated runtime type per settings +type that directly implements `IOptionsMonitor` and, through +`CompositionBuilder.UseOptions(source)`, coherently wires +`IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` from one +test-configured source of truth — per ADR-0061's Decision Outcome in +full, including its 2026-09-08 revision (public object model, +`Share()`-based `IOptions`/Monitor identity vs. fresh-per-resolution +`IOptionsSnapshot`, source non-disposal, and the concurrent-access +contract). + +Done when: every behavior in ADR-0061's Decision Outcome has a passing +deterministic automated test proving it; the real +`alexa-vox-craft`/`MediatRTestProfile.cs` `IOptions` +scenario is validated against freshly-packed local packages via +`scripts/dogfood-validate.sh` and demonstrably replaces its current +two-registration setup with one coherent call; the Configuration Cookbook +deliverable (ADR-0061's Documentation consequences, independent of this +package) is written; `skills/compono` (`SKILL.md` + a new +`references/options.md`) teaches an agent `Compono.Options`'s package +boundary, identity model, named-option divergence, and Finding B +limitation, validated by a baseline-vs-updated skill-eval comparison; and +package-validation/AOT/trimming checks pass alongside every other +publishable Compono package. + +## Scope + +Exactly ADR-0061's Decision Outcome (as revised 2026-09-08) — see that ADR +for the full rationale; this plan does not re-derive it. One cohesive +effort, one PR — no phase split; the work is one package plus its +required documentation, skill, and validation surface, not a +multi-milestone effort. + +**In scope:** +- `Compono.Options` package: `TestOptionsSource`, an internal + frozen-view type, `UseOptions` builder extension, + `UnconfiguredNamedOptionException`. +- `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` all satisfied + coherently per the ADR's identity model. +- Named options, deterministic changes, subscription disposal, the + concurrent-access contract. +- `Compono.Options.Tests` — deterministic contract tests for every ADR + behavior (§"Test Plan"). +- Package guide, skill reference, `evals.json` entries. +- Configuration Cookbook (ADR-0061 Documentation consequences — + independent of the package's own code, but this plan's definition of + done per the ADR). +- Dogfooding validation against real `alexa-vox-craft`. + +**Explicitly out of scope** (per ADR-0061, unchanged): +- Any solution to ADR-0052 Finding B, or automatic no-registration + composition of an arbitrary `T`. +- A `Compono.Configuration` package. +- Real `IConfiguration`/change-token/file-watcher simulation, + `IOptionsFactory` pipeline, DI-scope/container simulation. +- `CallVerifier`-based verification (deferred, not designed against here). +- Source generation, reflection. + +## Task 0 — Finalize public API surface (naming; not architecture) + +Names below are the finalized surface this plan implements — a naming +decision, not a reopening of ADR-0061's architecture. Checked against +existing conventions (`Register`/`Share()`/`UseNSubstitute()`/ +`UseBogus()`/`UseLogging()`/`Compono.Http`'s `TestHttpHandler`/ +`UnmatchedHttpRequestException`) before freezing: + +- [x] **`TestOptionsSource`** — the one public type per settings type + (`Compono.Options` namespace). Constructor: + `TestOptionsSource(T initialValue)` (sets `Options.DefaultName`'s + initial value; matches `TestHttpHandler`'s "construct directly, no + factory" shape). Implements `IOptionsMonitor` directly + (`CurrentValue`, `Get(string? name)`, `OnChange(Action)`). +- [x] **`.Change(T value)`** / **`.Change(string name, T value)`** — the + one mutation surface. Also how a named value is *first* established + (no separate "add" API — `Change` both creates and updates a named + entry, keeping the surface small per ADR-0061's cost-proportionality + driver). Named after the real contract's own vocabulary + ("`OnChange`," "options changed") rather than a generic `Set`/`Update` + that could be confused with `Compono.TestDoubles`'s `.Returns(...)` + configuration vocabulary. +- [x] **`CompositionBuilder.UseOptions(TestOptionsSource source)`** + — the one wiring call, matching the established `Use*` builder-extension + family exactly (`UseNSubstitute`/`UseBogus`/`UseLogging`). Internally: + registers `IOptions` and `IOptionsMonitor` via `Share()` + (one instance per graph — `IOptionsMonitor` *is* `source` itself; + `IOptions` is a frozen view captured once and shared); registers + `IOptionsSnapshot` as an ordinary, non-shared `Register` factory + (fresh frozen view per resolution). Reads naturally inline and inside + an `ICompositionProfile.Configure`, per ADR-0061. +- [x] **`UnconfiguredNamedOptionException`** — dedicated, package-owned + exception type (matching `Compono.Http`'s `UnmatchedHttpRequestException` + precedent, not a generic `KeyNotFoundException`/`InvalidOperationException`), + thrown by `Get(name)`/a frozen view's `Get(name)` when no value has been + configured for `name`. Message states: the settings type (`T`'s name), + the requested name, that it was never configured via `.Change(name, ...)`, + and points at `.Change(name, value)` as the fix — without hardcoding a + literal code sample that could drift from the real API if it changes + later. +- [x] Internal frozen-view type — **not public**, no name exposed in any + public API; implements `IOptions`/`IOptionsSnapshot` by capturing + `TestOptionsSource`'s state at construction time. + +## Task 1 — Package/project scaffolding + +Verified against `src/Compono.Http/Compono.Http.csproj` (closest +precedent: hand-written, non-generated, `Microsoft.Extensions.*`-adjacent) +and `src/Compono.Bogus/Compono.Bogus.csproj` (closest precedent for a +`PackageReference` to an external NuGet dependency beyond core `Compono`). + +- [x] `src/Compono.Options/Compono.Options.csproj` — `net8.0;net9.0;net10.0;net11.0`, + `LangVersion=latest`, `ImplicitUsings`/`Nullable` enabled, `Title`/ + `Description` following the established per-package copy style. + `IsAotCompatible` — **verify empirically** (per `Compono.Http`'s own + documented finding that this must be set explicitly and confirmed with + a real analyzer-contract test, not assumed) whether this package needs + it; likely yes, since it ships no `Requires*`-annotated members and + should build/consume cleanly under AOT/trim analysis the same way + `Compono.Logging`/`Compono.DependencyInjection` do. +- [x] `ProjectReference` to `..\Compono\Compono.csproj` + (`PrivateAssets="none"`, matching every integration package — lets + Compono's embedded analyzer flow through) + the + `PinProjectReferenceVersionsExact` target (copy verbatim from + `Compono.Http.csproj`/`Compono.Bogus.csproj`). +- [x] `PackageReference Include="Microsoft.Extensions.Options"` (no + inline version — centrally managed). +- [x] `Directory.Packages.props`: add per-TFM `Microsoft.Extensions.Options` + `PackageVersion` entries, following `Microsoft.Extensions.Logging.Abstractions`'s + existing four-TFM-conditioned-`ItemGroup` pattern exactly (net8/net9/net10/net11, + each pinned to that TFM's matching `Microsoft.Extensions.Options` + release line) — verify current released versions for each TFM at + implementation time rather than assuming today's numbers stay current. +- [x] `InternalsVisibleTo` → `Compono.Options.Tests`. +- [x] Add both projects to `Compono.slnx`. +- [x] `docs/packages/compono-options.md` package guide stub (content: Task + 9), added to `docs/packages/index.md`'s table and `docs/roadmap/index.md`'s + shipped-package list, `docs/index.md`'s package table, and root + `README.md`'s package table — the same discoverability surfaces every + prior package addition (`Compono.Http`/`Compono.Logging`/`Compono.NUnit`) + updated. Verify the exact current list of files needing this touch + against the most recent prior package PR rather than assuming this + list is exhaustive. +- [x] `docs/roadmap/future-packages.md`: move the `Compono.Options` entry + from "Roadmap items" to a shipped-package note once implementation + lands (mirroring how `Compono.TUnit`/`Compono.NUnit`/`Compono.Http` + graduated), and `docs/roadmap/proposed-adrs.md`: remove the ADR-0061 + entry once this plan reaches `Done` (per that page's own "entries + removed once implemented" rule). +- [x] CI/release integration: confirm the eight-(soon nine-)publishable-package + CI matrix (`docs/contributing.md`'s package-validation gate: API-compatibility + baseline, packed `.nupkg` contents inspection, local-feed consumer smoke + test) picks up `Compono.Options` automatically vs. needs an explicit + addition — inspect the actual CI workflow file(s) rather than assuming. + +## Task 2 — `TestOptionsSource` implementation + +- [x] Named-value storage satisfying ADR-0061's concurrent-access + contract: internally valid under concurrent reads/changes/subscribe/ + unsubscribe; no corruption across names or within one name. Choose the + smallest primitive that satisfies this (a single `lock` around the + store is very likely sufficient and clearer than a lock-free structure + for this access pattern — confirm against the Test Plan's concurrency + tests before over-engineering). +- [x] `Get(string? name)`: normalizes `null`/omitted to `Options.DefaultName` + (`string.Empty`); returns the stored value if configured; throws + `UnconfiguredNamedOptionException` otherwise. **Case-sensitive** name + comparison (ordinal), matching the real contract. +- [x] `CurrentValue => Get(Options.DefaultName)`. +- [x] `.Change(value)`/`.Change(name, value)`: establishes the new value + in the store **before** invoking any subscriber (ADR-0061's ordering + requirement — a callback reading `CurrentValue`/`Get(name)` mid-callback + observes the new value), then invokes `OnChange` subscribers + **synchronously**, for that name only (matching real + `OptionsMonitor`'s per-name `InvokeChanged`). +- [x] `OnChange(Action listener)`: a plain internal C# event + (`+=`/`-=`) — **not** a single-field assignment (the exact bug in the + community's naive fake) — returning a real `IDisposable` whose + `Dispose()` performs `-=` against the exact delegate instance + subscribed, and is **idempotent** (disposing twice is a no-op, matching + ordinary .NET subscription-disposable convention). **No per-subscriber + exception isolation** — a throwing subscriber blocks subsequent ones, + matching real `OptionsMonitor` exactly (do not add a `try`/`catch` + around each invocation). +- [x] **No `IDisposable`/`IAsyncDisposable` on `TestOptionsSource` + itself** — enforced by the type simply never implementing either + interface; add a test asserting this (`typeof(TestOptionsSource<>)` + does not implement `IDisposable`/`IAsyncDisposable`) so a future PR + can't silently reintroduce it. + +## Task 3 — Internal frozen-view type + +- [x] Captures `TestOptionsSource`'s current default/named values at + construction time (a snapshot copy, not a live reference into the + source's store) — implements `IOptions` (`Value` only) and + `IOptionsSnapshot` (`Value` + `Get(name)`), reading only its own + captured snapshot, never touching the source again after construction. + Also throws `UnconfiguredNamedOptionException` for an unconfigured name + at the moment of capture (matching the source's own behavior, since a + name absent at snapshot time is absent in the snapshot). + +## Task 4 — `UseOptions` builder extension + +- [x] `CompositionBuilder.UseOptions(this CompositionBuilder builder, TestOptionsSource source)`: + - `builder.Share>()` is **not** needed — + `TestOptionsSource` itself *is* `source`, registered directly: + `builder.Register>(() => source).Share>()` + (confirm exact call shape against `CompositionBuilder`'s real + `Share()`/`Register()` signatures during implementation — this + plan states the *intended effect*, not a guessed-at exact call + chain). + - `IOptions`: registered + shared, backed by one frozen view + captured once. + - `IOptionsSnapshot`: registered **without** `Share()` — a fresh + frozen view captured from `source`'s *then-current* state on every + resolution. + - Returns `CompositionBuilder` for fluent chaining, matching every + other `Use*` extension's shape. + +## Task 5 — Behavioral contract tests (`Compono.Options.Tests`) + +Deterministic, handwritten test data (per `testing.md` — this repo +doesn't use AutoFixture-style generated data for its own tests). + +**`IOptions`:** +- [x] `Value` reflects the source's value at the moment `IOptions` was + first resolved. +- [x] Same instance/value returned on every subsequent resolution within + one graph (shared identity). +- [x] Remains unchanged after a later `.Change(...)` on the source. + +**`IOptionsSnapshot`:** +- [x] A freshly-resolved snapshot reflects the source's *current* state. +- [x] Repeated reads (`Value`, `Get(name)`) on the *same* resolved + snapshot instance stay stable even if the source changes afterward. +- [x] A *second*, later resolution within the same graph, after a source + change, produces a *new* snapshot reflecting the new state (distinct + identity from the first). +- [x] Named values work identically to Monitor's (`Get(name)`). + +**`IOptionsMonitor`:** +- [x] `CurrentValue`/`Get(Options.DefaultName)` agree. +- [x] `Get(name)` for a configured name returns that name's value. +- [x] Same instance on every resolution within a graph (shared identity). +- [x] `.Change(value)` updates `CurrentValue` and fires subscribers. +- [x] Value is visible via `CurrentValue`/`Get` *from inside* a change + callback (ordering requirement). +- [x] Callbacks are synchronous (no `Task`/thread hop observed). +- [x] Two+ independent subscribers both fire on one change. +- [x] Disposing one subscription stops only that listener; a second, + still-subscribed listener keeps firing. +- [x] Disposing a subscription twice does not throw and does not affect + other subscriptions (idempotent). +- [x] A throwing subscriber prevents a later-registered subscriber in the + same invocation from firing (matches real behavior — assert this + explicitly as *intended*, not accidentally-discovered, behavior). + +**Named options:** +- [x] Default name (`Options.DefaultName`) and an explicit name are + independent. +- [x] Case-sensitive: `"Foo"` and `"foo"` are different names. +- [x] Changing one name does not affect another name's value. +- [x] `Get`/`.Value`/`.CurrentValue` for an unconfigured name throws + `UnconfiguredNamedOptionException` (Monitor, Snapshot, and — where + applicable at capture time — the frozen view backing `IOptions`). +- [x] Exception message names the settings type and the requested name. + +**Coherence (the central contract test):** +- [x] Configure one `TestOptionsSource`; wire via `UseOptions`; + resolve `IOptions`, `IOptionsMonitor`, and `IOptionsSnapshot` + all within the same graph; call `.Change(...)` on the source; assert: + Monitor sees the new value; the already-resolved `IOptions` stays at + its original value; the already-resolved Snapshot stays at its + original value; a *newly* resolved Snapshot after the change sees the + new value. One test, all four assertions — this is ADR-0061's central + claim, proven directly. + +**Registration/composition:** +- [x] Ordinary Compono duplicate-registration semantics hold, corrected + from this task's original "first-registration-wins" framing (see + ADR-0061 Amendment 1): an explicit consumer + `Register>(...)`/`Register>(...)`/ + `Register>(...)` collides with `UseOptions`'s own + internal registration for the same type and throws + `CompositionConfigurationException` at `Composer.Create`, regardless of + call order — `CompositionBuilder`'s existing, unchanged, strict + duplicate-registration rule (ADR-0019), not an override/precedence + mechanism. No special-cased behavior introduced by this package (test + against the real rule, not a restated assumption of it) — the wiring + call is the same ordinary call whether used inline or inside + `builder.Configure` — no special profile mechanism, no different + behavior inline vs. inside a profile. +- [x] `Share()` is used correctly for `IOptions`/`IOptionsMonitor` + and correctly *not* used for `IOptionsSnapshot` — assert both halves + explicitly (a regression that accidentally shares Snapshot, or stops + sharing `IOptions`, must fail a test). + +**Disposal:** +- [x] `TestOptionsSource` does not implement `IDisposable`/`IAsyncDisposable` + (type-level assertion, Task 2). +- [x] Multiple independent subscriptions dispose independently. + +**Concurrency (focused, not exhaustive stress testing):** +- [x] Concurrent reads (`CurrentValue`/`Get(name)`) from multiple threads + while a change is in flight never observe a torn/partially-written + value (assert the read is always *some* valid, previously-`Change`d + value, never a corrupted intermediate state). +- [x] Concurrent `.Change(...)` calls for *different* names don't corrupt + each other's storage. +- [x] Concurrent subscribe/unsubscribe calls, including concurrent with + an in-flight notification, don't throw or corrupt the subscriber list. +- [x] Not tested (explicitly, per ADR-0061): cross-test sharing of one + source instance — not a supported scenario, no test manufactures one. + +**Diagnostics:** +- [x] `UnconfiguredNamedOptionException`'s message is asserted verbatim + (or via a stable substring match) in at least one test per interface + surface that can throw it (Monitor, Snapshot). **Implementation note:** + `IOptions` cannot observably throw this exception in practice — it + exposes no named-lookup surface (per implementation clarification #1; + only `Get(name)` on Monitor/Snapshot accepts a name), and its one lookup + path (the default name) is always established by + `TestOptionsSource`'s constructor. See Notes. + +## Task 6 — Native AOT / trimming validation + +- [x] `Compono.Options.AotSmokeTest` (or equivalent), following + `Compono.Http.AotSmokeTest`'s established shape — confirm `PublishAot` + succeeds with zero `IL2xxx`/`IL3xxx` warnings from this package's own + code. +- [x] Confirm no reflection anywhere in `Compono.Options`'s own dispatch + (a direct code-reading check, the same "confirms this project's own + dispatch code stays genuinely reflection-free" validation + `Compono.Http.csproj`'s comments describe doing for itself). + +## Task 7 — Package-validation / quality gates + +- [x] Nullable annotations clean (no warnings) across the package. +- [x] XML doc comments on every public member (repo-wide hard requirement + — `documentation.md`). +- [x] Public-API-compatibility baseline file — **not applicable**: verified + no sibling package (`Compono.Http`, `Compono.NUnit`, etc.) has a + per-package `PublicAPI.txt`/ApiCompat-baseline file in this repo; API + compatibility is enforced generically by `package-validation.yaml`'s + `PackageValidationBaselineVersion`/nuget.org-baseline mechanism (no + baseline exists yet for a package's first publish), which + `Compono.Options`'s addition to `PACKAGES` (Task 1) already opts into — + no package-specific file needed. +- [x] Packed `.nupkg` contents inspected (README embedded, correct + `TargetFrameworks`, no accidental extra content) per the existing + package-validation gate. +- [x] Confirm no accidental transitive `Microsoft.Extensions.DependencyInjection` + dependency is pulled in beyond what `Microsoft.Extensions.Options` + itself requires. + +## Task 8 — Registration-order/profile dogfooding inside this repo + +- [x] A sample/usage snippet (in the package guide, Task 9, and/or a + `Compono.Options.SampleTests`-shaped project if this repo's existing + per-package sample-project convention applies here — check + `Compono.Http.SampleTests`/`Compono.Logging`'s equivalent for + precedent) exercising `UseOptions` both inline and inside an + `ICompositionProfile`. + +## Task 9 — `Compono.Options` package documentation + +`docs/packages/compono-options.md`, matching the established package-guide +shape (`compono-http.md`/`compono-logging.md` as the template): + +- [x] Installation. +- [x] Basic setup — `IOptions`. +- [x] `IOptionsSnapshot` — the identity model stated in consumer terms + ("a fresh view each time it's resolved, reflecting the source's current + state") without requiring the reader to know `Share()` is involved + internally. +- [x] `IOptionsMonitor` — `CurrentValue`, `Get(name)`, `.Change(...)`, + `OnChange`/disposal. +- [x] Named options, including the case-sensitivity note. +- [x] Deterministic changes and the ordering guarantee (`.Change` + establishes the value before subscribers run). +- [x] Inline usage and profile usage, **using the real + `MediatRTestProfile.cs` before/after** (§"Dogfooding," below) as the + worked example. +- [x] Explicit "Unconfigured named options" section stating the + intentional divergence from real `IOptionsFactory`'s silent-default + behavior, and why (ADR-0045 precedent). +- [x] "What this package doesn't do" — no `IConfiguration`/change-token + simulation, no `IOptionsFactory` pipeline, no DI-scope simulation, + no automatic no-registration composition (ADR-0052 Finding B). +- [x] Cross-link to the Configuration Cookbook (Task 10) for + `IConfiguration` itself. + +## Task 10 — Configuration Cookbook (required deliverable, independent of the package's code) + +Per ADR-0061's Documentation consequences — this is **not optional +follow-up**; it's part of this plan's own definition of done even though +no `Compono.Configuration` package exists. Location: this repo's existing +Cookbook structure (`docs/cookbook/` — verify exact directory/index +convention against an existing recipe before adding a new one). + +- [x] **Basic in-memory `IConfiguration` composition** — + `ConfigurationBuilder`/`AddInMemoryCollection`/`Build()` + + `Register(...)`. +- [x] **Layered configuration / test-specific overrides** — later + `AddInMemoryCollection` source wins; a base-values-plus-override-values + example. +- [x] **Reusable configuration through a profile** — an + `ICompositionProfile` establishing baseline configuration once, + reused/varied per test. +- [x] **`GetSection`/common consumption patterns** — brief, not a general + Microsoft Configuration tutorial. +- [x] **Relationship to `Compono.Options`** — explicit routing: ordinary + Configuration + `Register` for `IConfiguration` itself; + `Compono.Options` for the Options interfaces; no `Compono.Configuration` + package exists, stated plainly. +- [x] Cross-link from `docs/packages/compono-options.md` (Task 9) and + from `docs/roadmap/future-packages.md`'s "Documentation-only ideas" + entry (mark it fulfilled once this recipe is written). + +## Task 11 — Skill and evals (mandatory, not optional cleanup) + +- [x] `skills/compono/SKILL.md` — add `Compono.Options` to the + package-detection table, following the existing per-package row shape. +- [x] `skills/compono/references/options.md` — new reference file + (verify against the existing per-package reference files' shape/depth, + e.g. `references/http.md`/`references/logging.md`), covering: when to + recommend `Compono.Options`; the identity model in agent-actionable + terms; the `IConfiguration`-vs-`Compono.Options` routing distinction + (pointing at the Configuration Cookbook for the former); the + intentional unconfigured-named-option-throws behavior; the ADR-0052 + Finding B limitation (an agent should never suggest "just declare the + dependency with no registration" for this package). +- [x] `skills/compono/evals/evals.json` — add eval scenarios: recommending + `Compono.Options` for a Monitor/Snapshot need; correctly routing a + plain `IConfiguration` need to the Cookbook pattern instead of + inventing a `Compono.Configuration` package; correctly explaining the + unconfigured-named-option throw when asked "why did my test throw + `UnconfiguredNamedOptionException`." +- [x] Run the mandatory baseline-vs-updated skill-eval comparison in + clean agent contexts, per this repo's established skill-eval workflow. + Keep generated eval workspaces out of source control. + +## Task 12 — Dogfooding validation (`alexa-vox-craft`) + +Per ADR-0061's dogfooding validation and this plan's own Scope — **the +real target is composition-ergonomics validation, not Monitor/Snapshot +coverage**, which this plan does not claim and should not manufacture. + +- [x] Run `scripts/dogfood-validate.sh` against + `/Users/ncipollina/source/repos/layered-craft/alexa-vox-craft`, + packing `Compono`, `Compono.Options`, and whatever else that repo's + suite already depends on, from this working tree's current state, into + a local feed — no `ProjectReference` bypass. +- [x] In an isolated branch/worktree of `alexa-vox-craft` (never commit + against the real repo from this task), replace + `MediatRTestProfile.cs`'s + `Register(...)`/`Register>(...)` + pair with the `Compono.Options` equivalent and confirm the suite still + passes. +- [x] Record the before/after in the package guide (Task 9) as the + worked profile example. +- [x] **If the resulting API is awkward, unnecessarily verbose, or + doesn't genuinely improve this real scenario, stop and report that + evidence rather than rationalizing the design** — per this plan's + explicit instruction; do not silently patch around a bad finding here. +- [x] Do **not** attempt to manufacture `IOptionsMonitor`/ + `IOptionsSnapshot` usage in `alexa-vox-craft` merely to claim + dogfooding coverage for those interfaces — their correctness is + validated by Task 5's deterministic contract tests instead, and + ADR-0061's own honest disclosure of this gap stays accurate. + +## Critical Files + +- `src/Compono.Options/Compono.Options.csproj` — new package project. +- `src/Compono.Options/TestOptionsSource.cs` — the public source/Monitor + type. +- `src/Compono.Options/*` — internal frozen-view type, `UseOptions` + builder extension, `UnconfiguredNamedOptionException`. +- `test/Compono.Options.Tests/**` — contract tests (Task 5). +- `test/Compono.Options.AotSmokeTest/**` — AOT validation (Task 6). +- `Directory.Packages.props` — `Microsoft.Extensions.Options` per-TFM + versions. +- `Compono.slnx` — new project entries. +- `docs/packages/compono-options.md`, `docs/packages/index.md`, + `docs/index.md`, `README.md`, `docs/roadmap/index.md` — discoverability + surfaces. +- `docs/cookbook/**` — the new Configuration recipe (Task 10). +- `docs/roadmap/future-packages.md`, `docs/roadmap/proposed-adrs.md` — + status updates once shipped. +- `skills/compono/SKILL.md`, `skills/compono/references/options.md`, + `skills/compono/evals/evals.json`. + +## Test Plan + +See Task 5 in full — deterministic, handwritten test data throughout +(`testing.md`); every ADR-0061 behavior gets its own named test, not a +single monolithic scenario, except the coherence test (§"Coherence"), +which is deliberately one test proving the central cross-interface claim +in one place. Concurrency tests are focused correctness checks (no torn +state, no corrupted store), not stress/performance benchmarks. AOT/trim +validated by Task 6's smoke test. Real-consumer validation via Task 12's +dogfooding — explicitly scoped to composition-ergonomics, not +Monitor/Snapshot coverage. + +## Notes + +**Implementation-clarification memo (pre-implementation, incorporated +before any code was written):** three clarifications refined Task 0/2/5 +without reopening ADR-0061's architecture: (1) `IOptions` has no +`Get(name)` named-lookup surface — only `Value`; tests and docs were +written against the real interface contract, not an invented one. (2) The +identity/lifetime contract is what's load-bearing, not literally calling +`Share()`; the implementation does call it (`Register>(() => source).Share>()`, +same for `IOptions`) because it was in fact the smallest, most direct +way to satisfy the contract — no contortion was needed. (3) `Change(value)`/ +`Change(name, value)` as both setup and mutation was pressure-tested during +dogfooding and found natural, not awkward — no evidence emerged to expand +the surface. + +**API naming finalized exactly as Task 0 anticipated** — no deviation: +`TestOptionsSource`, `.Change(T)`/`.Change(string, T)`, +`CompositionBuilder.UseOptions(source)`, `UnconfiguredNamedOptionException`. + +**Diagnostics coverage gap (expected, not a defect):** `IOptions` can +never observably throw `UnconfiguredNamedOptionException` in practice — its +one lookup path (the default name) is always established by +`TestOptionsSource`'s constructor, and it exposes no named-lookup +surface at all (implementation clarification #1). Task 5's diagnostics +test coverage is therefore Monitor + Snapshot only, not all three +interfaces — this was anticipated, not discovered as a surprise. + +**`AttributeAccessedMemberTypes`/AOT annotation needed, not anticipated by +the Plan:** `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor`'s real +Microsoft signatures carry +`[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]` +on their `TOptions` type parameter (for `IOptionsFactory`'s own +reflection-based construction path elsewhere in the framework, unused by +this package). `TestOptionsSource`/`FrozenOptionsView`/`UseOptions` +had to carry the identical attribute on their own `T` to satisfy the +trim/AOT analyzer once `IsAotCompatible=true` was set (IL2091 otherwise) — +a mechanical propagation, not a design decision; no behavior change. + +**Namespace collision, mechanical fix:** this package's own namespace is +`Compono.Options`, colliding with `Microsoft.Extensions.Options`'s +`Options` static class (`Options.DefaultName`). Resolved with a `using +MSOptions = Microsoft.Extensions.Options.Options;` alias — purely a naming +mechanic, no public API surface affected. + +**Registration-precedence test finding — genuinely contradicted a specific +ADR-0061 claim, corrected via Amendment 1, not just imprecise phrasing:** +ADR-0061's original Decision Outcome claimed an explicit consumer +registration written after `UseOptions` "interacts through Compono's +ordinary, unchanged first-registration-wins rule" and that such a consumer +"still gets ordinary, predictable Compono behavior" — describing a working +override. Real `CompositionBuilder.Register` has no such override +mechanism for an exact-type collision: registering the same type twice +(once by a consumer, once by `UseOptions`'s own internal wiring) is +always a strict build-time `CompositionConfigurationException` +(ADR-0019), regardless of order — there is no partial override of one of +the three Options interfaces while keeping `UseOptions`'s coherence +guarantee for the other two. This is still "no special-cased precedence +invented for this package" (Compono's real rule applies unmodified), but +the ADR's own description of what that rule *does* was factually wrong, +not just loosely worded — recorded as **ADR-0061 Amendment 1 +(2026-09-08)**, not silently rewritten in place. The test +(`OrdinaryFirstRegistrationWinsPrecedence_HoldsUnchanged_ForAnExplicitConsumerOverride`, +`CompositionBuilderExtensionsTests.cs`) proves the real behavior (a thrown +`CompositionConfigurationException`) directly. + +**`Microsoft.Extensions.Options` net11.0 packaging quirk — pre-existing, +confirmed not package-specific:** the packed `.nuspec`'s net11.0 dependency +group carries no explicit `Microsoft.Extensions.Options` entry (satisfied +by net11.0's own shared framework) — identical to `Compono.Logging`'s +existing, `Accepted` `Microsoft.Extensions.Logging.Abstractions` net11.0 +behavior. Confirmed via a real local pack + `inspect-packed-nupkgs.sh` +(extended with a `Compono.Options` case block, Task 7) before concluding +this, not assumed. + +**Dogfooding — real evidence, environment obstacle resolved without +weakening the check; also the evidence behind ADR-0061 Amendment 1's +second correction** (coherence is guaranteed among `IOptions`/ +`IOptionsSnapshot`/`IOptionsMonitor`, not between the bare settings +type `T` and those three merely because `T` happens to be registered +separately — `UseOptions` never touches a plain `Register()`)**:** +the `alexa-vox-craft` consumer repo has 25 projects; +`SkillServiceConfiguration` is consumed both as `IOptions` +(`SkillMediatorTests`/`DefaultResponseBuilderTests`) and as the bare type +(`ServiceRegistrarTests`) in the same test project, so `UseOptions` +alone couldn't replace the old two-registration shape 1:1 — the plain +`Register` registration was kept, now sourced +from the same instance passed to `TestOptionsSource`, rather than a +second independently-constructed one (see the package guide's real +before/after). Running the dogfooding validation from a `/tmp`-rooted git +worktree hit a macOS `/tmp`→`/private/tmp` symlink path-duplication +artifact that broke assembly loading for *every* project in the consumer +repo (confirmed byte-identical on completely unmodified `main` code, ruling +out any connection to this change) — resolved by moving the isolated +worktree to a non-`/tmp` sibling directory, not by weakening the +validation. Final result: `scripts/dogfood-validate.sh` — full +`alexa-vox-craft` solution, 2564/2564 tests passed, `Compono.Options` +(and every other requested package) confirmed resolved to the exact +freshly-packed local version. The real repo's working tree was untouched +throughout (worktree + branch deleted after). + +**Full local-solution `dotnet test` note (this repo, not the consumer):** +one full `Compono.slnx` run was killed by the OS (SIGKILL, exit 137) after +an anomalous 12-minute hang in `Compono.Logging.Tests` (net9.0) under +heavy parallel resource contention — before the kill, all 3736 tests +including every `Compono.Options.Tests` case had already passed (0 +failed). Re-ran `Compono.Logging.Tests` in isolation immediately after: +248/248 passed in ~5s, confirming the hang was a resource-contention flake +unrelated to this change, not a regression. + +**One factual correction to ADR-0061 was needed (Amendment 1), no +architectural contradiction.** The registration-precedence finding above +corrected a specific, wrong factual claim in the original Decision +Outcome text (a described override mechanism that doesn't exist) — this +is recorded as a dated Amendment, not a silent rewrite, per this repo's +ADR-immutability convention. It does not touch the core decision (object +model, identity/lifetime contract, `Share()`'s role, disposal, or the +concurrency contract) — those hold exactly as decided. Every other +finding during implementation, testing, and dogfooding was an anticipated +clarification, a mechanical AOT/namespace fix, or an environment artifact +resolved without touching validated behavior. + +**Final review pass (2026-09-08), before commit/PR:** an independent +correctness/design-consistency review of the working tree against +ADR-0061 found the implementation, tests, AOT proof, and dogfooding result +were all correct as reported — no code defects, no concurrency defects, no +public-API concerns. It found two documentation defects, both wording +overreach rather than implementation bugs, both now corrected: (1) the +"first-registration-wins"/override framing addressed above was still +present verbatim in ADR-0061's original Decision Outcome prose, this +plan's own Task 5 checklist item, and the Configuration Cookbook's +"Reuse Configuration Through a Profile" recipe (whose own worked example +would have thrown `CompositionConfigurationException` if followed +literally — fixed to pass the override through the profile's constructor +instead of a second `Register` call); (2) ADR-0061's +"Selected dogfooding validation target" prose overstated +`Compono.Options` as collapsing the bare-`T` + `IOptions` registrations +into one call — corrected (ADR-0061 Amendment 1, second paragraph) to +state precisely what's guaranteed (coherence among the three Options +interfaces, from one source) versus what's just consumer discipline +(sharing one instance between a separate `Register` and +`UseOptions`). Both corrections are recorded as ADR-0061 Amendment 1, +not silent rewrites of the immutable Accepted text. No code, test, or +public API change resulted — `Compono.Options.Tests` re-run clean +(160/160) after the doc corrections, confirming no runtime behavior was +touched. diff --git a/docs/plans/README.md b/docs/plans/README.md index 324b995b..0ca9bd03 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -78,3 +78,4 @@ one. This file is just the mechanics: numbering, status, and the index. | [0061](0061-pre-1-0-cleanup-and-consolidation.md) | Pre-1.0 Cleanup and Consolidation Gate | Done | | [0062](0062-package-validation-gap-fixes.md) | Package-Validation Gap Fixes | Done | | [0063](0063-callverifier-atleast-atmost-and-testdoubles-received-calls-clear-calls.md) | `CallVerifier.AtLeast`/`AtMost`, `Compono.TestDoubles` `ReceivedCalls()` + `ClearCalls()` | Done | +| [0064](0064-compono-options-testing-support.md) | Compono.Options: First-Class .NET Configuration/Options Testing Support | Done | diff --git a/docs/research/0028-compono-options-configuration-admission-research.md b/docs/research/0028-compono-options-configuration-admission-research.md new file mode 100644 index 00000000..961b853d --- /dev/null +++ b/docs/research/0028-compono-options-configuration-admission-research.md @@ -0,0 +1,1145 @@ +# [RESEARCH-0028] .NET Configuration/Options Testing Support: Capability & Package Admission Research + +**Status:** Research complete, reassessed once (2026-09-07, same day as +original). No ADR yet — this document is the pre-ADR evidence base for a +future problem-only `Proposed` ADR, per `design-decisions.md`'s rule that +a design dive's research phase precedes drafting one, and per this +investigation's own explicit scope (admission recommendation only, no +design, no ADR). + +**Governing process:** [`docs/architecture/capability-admission.md`](../architecture/capability-admission.md), +applied faithfully end to end. Gate A (architectural admission) and Gate B +(evidence admission) are evaluated as separate questions, per capability +slice — not as one blanket verdict for "Configuration/Options." + +**Trigger:** an explicit product-owner request (Nick Cipollina, 2026-09-07) +for first-class Compono support in .NET Configuration/Options testing, +explicitly conditioned on the request only counting as Gate B evidence for +whatever survives Gate A on its own architectural merits — not a mandate +to admit a package. **Reassessed the same day** against a sharpened +version of that request — see **Reassessment**, below the Executive +Conclusion — clarifying that the underlying product goal is composition +ergonomics ("make dependencies easier, more obvious, and more pleasant to +compose in tests") as legitimate value in its own right, not merely a +request to wrap already-simple APIs. + +**Candidate framing, as instructed:** not "should we build `Compono.Options`" +or "should we build `Compono.Configuration`" — package names and +boundaries are this document's *conclusion* (§17), not its premise. The +actual question: *should Compono provide first-class testing/composition +capabilities for .NET Configuration and/or Options, and if so, for which +concrete problems?* + +**2026-09-07 reassessment note:** this document was revisited once, +against clarified product-owner context about composition ergonomics as +legitimate Compono-specific value in their own right. See the +**Reassessment** section immediately below for what changed, what didn't, +and why. Sections carrying a **(Reassessed 2026-09-07)** marker were +revised; all others are unchanged from the original investigation. + +--- + +## Reassessment (2026-09-07): composition ergonomics as legitimate Compono-specific value + +**What prompted this.** The original investigation treated "the raw .NET +API is already simple" as sufficient reason to stop at Step 2 of the +admission process for both Configuration and `IOptions`. The +product owner pushed back on that inference specifically — not by +disputing that the raw APIs are simple, but by asking whether "simple to +construct" and "natural to compose *inside Compono's model*" are the same +question. They aren't, and treating them as interchangeable was this +document's one real gap. + +**The sharper question, stated precisely:** not *"is the .NET construction +call short?"* but *"does a Compono user stay inside Compono's composition +model to get this dependency, or do they have to drop out of it and +reconstruct framework-specific ceremony by hand, from memory, every time?"* +A short construction call can still represent real, repeated, avoidable +friction if a consumer has to (a) remember which of three related +interfaces they need, (b) remember the wrapping/registration idiom, and +(c) keep multiple related registrations consistent with each other by +hand. None of that is captured by "how many lines is the `new +ConfigurationBuilder()` call." + +**Does this lower the admission bar?** No — and this reassessment is +explicit about why not. `docs/architecture/capability-admission.md`'s +Step 2 ("boilerplate alone does not justify a new abstraction") stays +exactly as written and is applied just as strictly below. What changes is +*which question counts as evidence for Step 3's "meaningful abstraction" +criterion* — not whether that criterion still has to be cleared on real +evidence. A capability that survives this reassessment still has to show +something more than line-count reduction; several slices reassessed below +(Configuration, in particular) are re-examined under the sharper question +and still land in the same place, for a sharper reason. + +**Repository evidence that Compono already treats this as legitimate +product value, independent of raw technical difficulty:** + +- **`CompositionBuilder.Share()` (ADR-0056).** Before this shipped, + sharing a value across a composition graph was **already fully + possible** via `[Shared]` (ADR-0011/ADR-0022) — nothing was technically + blocked. `Share()` was admitted anyway because expressing sharing as + **graph-wide, profile-embeddable, framework-independent composition + configuration** — rather than a per-test-signature attribute a consumer + has to remember to attach on every relevant parameter — was itself + judged to be real product value. ADR-0056's own Context is explicit: + the gap was that "the sharing intent was really a property of the + *composition configuration*... not of any one test." This is the exact + shape of argument the product owner is making for Configuration/Options. +- **`Compono.Bogus` (ADR-0027).** Constructing a string that looks like a + plausible email address (`"jane@example.com"`) is not technically hard + — a consumer could always hand-write one. `Compono.Bogus` was admitted + anyway, explicitly framed around ergonomics and discoverability ("just + call `UseBogus()` and get realistic values" — ADR-0027's own Context), + not around raw difficulty. + +**Conclusion from this evidence:** Compono's own accepted history does +**not** require an underlying operation to be difficult before granting it +first-class composition ergonomics. It has, at least twice, admitted a +capability whose primary value is discoverability, consistency, and +staying inside the composition model — precisely the dimension the +original research under-weighted. This is an existing, evidenced Compono +product principle, not one invented for this candidate — see +**Consideration for `capability-admission.md`** below for whether the +process document itself should say this more explicitly. + +**What this does *not* change:** §7–§9's `IOptionsMonitor` correctness +findings (named options, multi-subscriber `OnChange`, real disposal, +thread-safety, "don't simulate the full reload pipeline") and §13/§14's +ADR-0052 Finding B analysis are **preserved unchanged** — they were never +about raw-difficulty reasoning in the first place; they already rested on +documented behavioral gaps in existing practice, which is a stronger +evidence bar than this reassessment's question even asks for. + +**Consideration for `docs/architecture/capability-admission.md`:** the +process document's Step 1–2 framing ("a concrete problem," "check whether +it's already solved") reads, on its own, as if raw construction difficulty +is the primary signal — it doesn't currently name "does this let a +consumer stay inside Compono's composition model, expressed once and +reused via profiles, instead of reconstructing framework ceremony +repeatedly" as its own recognized form of evidence, even though +`Share()`/`Compono.Bogus` show this repo already accepts exactly that +argument. This looks like a real, if minor, gap in how the consolidated +document explains itself — flagged here as instructed, **not corrected in +this pass**: this task is scoped to reassessing RESEARCH-0028, and +`capability-admission.md` is only to be changed if the engineering +workflow explicitly requires it for this outcome, which it does not. + +--- + +## 1. Executive conclusion (Reassessed 2026-09-07) + +Configuration/Options still decomposes into independently-answerable +slices — that structure was correct and is preserved — but the reasoning +behind two of them changed, and one new coherent capability emerges from +combining ergonomics with the original correctness analysis: + +| Slice | Outcome | +|---|---| +| Consistent `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` composition for a given `T`, backed by one coherent, deterministic, discoverable implementation | **Roadmap item** | +| `IOptions` *alone*, with no Monitor/Snapshot need | **Already solved** — no new capability (acceptable Compono-native alternative, existing); the ergonomic case only strengthens once Monitor/Snapshot need a package anyway (§5, §16) | +| Automatic composition of `IOptions`/`IOptionsMonitor` for *any* composed `T` with no manual registration | **Documentation-only**, pending a prerequisite core decision (ADR-0052 Finding B, still open) — unchanged by this reassessment (§14) | +| `IConfiguration`/`ConfigurationBuilder` composition (in-memory sources, binding, layered overrides) | **Documentation-only** — reassessed under the sharper ergonomics question and still lands here, for a sharper reason (§3) | +| Options validation (`IValidateOptions`, `DataAnnotations`, source-generated validators) | **Rejected** — unchanged; still a production/runtime concern, not a testing/composition gap | + +**What changed:** the original document treated `IOptionsMonitor` and +`IOptionsSnapshot` as two separately-scored slices that happened to +share cheap implementation. The reassessment reframes them as one +**coherent capability** — consistent, correctly-wired composition across +all three related Options interfaces for a given `T` — because the real +product risk this uncovers is a consumer wiring `IOptions` to one value +and `IOptionsMonitor` to a different, inconsistently-constructed one +(a genuine, subtle correctness trap a hand-rolled setup invites), not +merely "three interfaces are annoying to remember separately." This is a +materially stronger justification for `IOptionsSnapshot`'s inclusion +than "cheap to implement alongside Monitor" — see §6. + +**Recommended package boundary — unchanged from the original conclusion:** +a single new package, **`Compono.Options`** — hand-written (non-generated) +runtime classes providing correct, consistently-wired `IOptions`/ +`IOptionsSnapshot`/`IOptionsMonitor` test doubles, architecturally a +sibling of `Compono.Http` (depends only on core `Compono`, no generator +dependency, no reflection). **`Compono.Configuration` should still not +exist** — reassessed under the sharper ergonomics question in §3, not +merely re-asserted. Nothing belongs in `Compono.DependencyInjection` — +unchanged (§12). + +**Gate B:** satisfied for the `Compono.Options` roadmap item by the +explicit, now-more-precise product-owner request (§19), the same +mechanism that already cleared `Compono.TUnit`, `Compono.NUnit`, and +Compono-owned source-generated test doubles. No dogfooding evidence was +manufactured or required for this to count. + +--- + +## 2. Candidate/problem statement + +Stated per the admission process's Step 1 (a concrete problem, not "this +would be nice" or "other libraries have this"): + +.NET's Options pattern (`IOptions`/`IOptionsSnapshot`/`IOptionsMonitor`) +is the standard, idiomatic way a modern .NET application receives +strongly-typed configuration. Any real application composed with Compono +that reads configuration through this pattern needs its test doubles +composed the same way every other dependency is — but `IOptionsMonitor` +and `IOptionsSnapshot` have no first-party test double at all (§4), and +the community's own standard answer (a hand-rolled fake, §7) is +demonstrably incomplete in ways that make it a *misleading* fake, not +merely an inconvenient one. This is a real, evidenced problem (§4, §7), +not a speculative one. + +--- + +## 3. Existing .NET Configuration testing story (Reassessed 2026-09-07) + +`IConfiguration`/`IConfigurationRoot`/`IConfigurationSection`/`ConfigurationBuilder` +already have a trivial, first-party, in-memory testing path: + +```csharp +IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Smtp:Host"] = "localhost", + ["Smtp:Port"] = "2525", + }) + .Build(); +``` + +- `AddInMemoryCollection` is purpose-built for tests — no file system, no + environment variables, fully deterministic. +- `GetSection`/`GetValue`/`Bind`/`Get` all work identically against + an in-memory source as against any other provider — there is no + Configuration-specific behavior a test needs that production code + doesn't already exercise the same way. +- **Binding is already source-generation-first when it matters.** + `Microsoft.Extensions.Configuration.Binder` ships a Roslyn source + generator (`EnableConfigurationBindingGenerator`) that rewrites + `ConfigurationBinder.Bind`/`Get` call sites to generated, + reflection-free code — enabled automatically whenever a project sets + `PublishAot`. .NET has already solved the exact "source-generated, + AOT-safe configuration binding" problem Compono's own architecture + cares about, for the exact same reason Compono cares about it. (See + [Compile-time configuration source generation](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration-generator).) + +**Reassessed against the sharper question (§"Reassessment," above): does a +Compono user stay inside the composition model, or drop out of it?** +`Register(() => new ConfigurationBuilder()...)` keeps a +consumer entirely inside ordinary Compono idiom — `Register` already +generically handles `IConfiguration` (or any other type) with zero +Compono-specific ceremony beyond a call every consumer already has to +learn once for any registered type. There is no analog here to +`Share()`'s actual gap (an implementation detail — *that* a value is +shared — leaking into every relevant test signature) or to the real +Options-consistency risk found in §5/§6 (multiple related interfaces that +must stay wired to the same value). Layering a base configuration with +test-specific overrides — one of the scenarios flagged for reassessment — +is also already a "few obvious lines" under the sharper question, not just +the original one: `AddInMemoryCollection(base).AddInMemoryCollection(overrides)`, +later source wins, no hidden precedent to memorize. **This is a case where +the sharper ergonomics question was applied in good faith and still +produces the same answer, for a sharper reason**: not because the raw API +is short, but because nothing about *staying inside Compono's model* is +harder for Configuration than for any other `Register`-shaped type, +and there is no multi-interface-consistency trap analogous to Options'. + +**Conclusion (unchanged):** there is no genuine Configuration-testing gap +for Compono to fill, under either framing. The only thing worth producing +here is a Cookbook recipe showing this pattern next to Compono composition +(e.g. composing a type that takes `IConfiguration` via +`Register(() => ...)`, including the layered-override +variant) — not a package, and not a dedicated `Use*` builder extension +either, since a named wrapper over one `Register` call for one +already-simple type would itself fail "meaningful abstraction" the same +way a trivial extension method always does. + +--- + +## 4. Existing .NET Options testing story + +Per current (`.NET 8`/`9`, verified against +[Options pattern - .NET | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/core/extensions/options), +updated 2026-05-15) first-party guidance: + +| Interface | Lifetime | Named options | Change notifications | First-party test double? | +|---|---|---|---|---| +| `IOptions` | Singleton | No | No | No, but trivial: `Options.Create(value)` | +| `IOptionsSnapshot` | Scoped | Yes (`Get(name)`) | Recomputed per scope, not pushed | **None** | +| `IOptionsMonitor` | Singleton | Yes (`Get(name)`) | Yes (`OnChange`, `CurrentValue`) | **None** | + +`IOptionsFactory` (creates instances from registered +`IConfigureOptions`/`IPostConfigureOptions`), +`IOptionsMonitorCache` (invalidates/replaces cached named instances), +and `IOptionsChangeTokenSource` (supplies the `IChangeToken` a monitor +watches) are the machinery `OptionsMonitor`'s real implementation is +built from — not something an application or test author normally +implements directly, but directly relevant to what a *correct* test double +has to reproduce (§7). + +**These three interfaces are not equivalent wrappers around a value** — +this distinction is the crux of the whole investigation: + +- `IOptions` — one value, computed once, never changes. Testing it is + already solved (`Options.Create(...)`). +- `IOptionsSnapshot` — recomputed once per DI scope; supports named + options; does **not** push changes mid-scope. A unit test rarely spins + up a real DI scope, so what it actually needs from a snapshot fake is + "a fixed value, addressable by name" — not scope-lifecycle simulation. +- `IOptionsMonitor` — one long-lived instance for the app's lifetime; + supports named options *and* live change notification via `OnChange`. + This is the one with real behavioral surface a fake has to get right: + multiple independent subscribers, per-subscriber disposal, and a + `CurrentValue`/`Get(name)` that actually changes when the underlying + value changes. + +--- + +## 5. `IOptions` — detailed analysis (Reassessed 2026-09-07) + +**Production shape:** `IOptions.Value` — one non-nullable property. + +**Already solved, twice over, inside this repo's own ecosystem:** + +1. **As a mock/stub double.** `Compono.TestDoubles` v2 already generates a + working double for `IOptions` — confirmed by real dogfooding + (`docs/research/0005-lightsaber-skill-testdoubles-v2-third-dogfood.md`): + `IOptions` **generates and resolves** via + `Configure().Value().Returns(...)`, once ADR-0045's + configuration-required-members mechanism closed the `CMP0025` + whole-interface-rejection gap that originally blocked it (a correction + recorded in ADR-0044 Amendment 17). No new Compono capability is needed + for "give me an `IOptions` returning an arbitrary configured value." +2. **As a value composed from Compono's own composition of `T`.** Real + consumer code already does this by hand today, via an ordinary + registration: + + ```csharp + builder.Register>(context => + Options.Create(context.Resolve())); + ``` + + This is genuine, working Compono idiom (seen in + `docs/research/0001-autofixture-comparison.md`, + `docs/research/0011-...testkit-migration-slice-1.md`) — **and** it is + the exact pattern that surfaced ADR-0052 Finding B (§13, §14): when + `MyOptions` isn't independently discovered as a root elsewhere, + `context.Resolve()` inside this factory has no generated + plan to fall back to, and fails at test-run time with a + `CompositionException`. This is not a defect specific to `IOptions` + — it's ADR-0052's already-recorded, currently-*unresolved* nested-resolve + discovery gap, which affects any registration factory reaching for a + type only known through its own body, not something specific to the + Options pattern. + +**Reassessed against the sharper ergonomics question:** taken in complete +isolation — a SUT that only ever needs `IOptions`, never `Snapshot`/ +`Monitor` — this remains a case where "does the consumer stay inside +Compono's model" and "is the raw API short" happen to agree: `Register>(() => +Options.Create(value))` is exactly as much Compono idiom as any other +`Register` call, with no separate ceremony to remember and no +multi-interface consistency risk (there's only one interface in play). +Standing entirely alone, this still does not clear "meaningful +abstraction" any more than Configuration does (§3) — a dedicated +`IOptions`-only wrapper would be exactly the kind of trivial, +name-only convenience the admission process's Step 2/3 exist to catch. + +**But `IOptions` is not always standing alone.** The real ergonomic +risk is specific to a SUT that depends on `IOptions` *and* +`IOptionsMonitor` (or `IOptionsSnapshot`) for the *same* underlying +settings type — a genuinely common shape (a component reads +`IOptions.Value` once at construction while another part of the same +system watches `IOptionsMonitor` for live changes). Wired by hand, a +consumer has to remember to construct both from the *same* value and keep +them consistent as the test evolves; nothing prevents them silently +drifting apart. This is exactly the kind of "reducing opportunities to +construct subtly incorrect substitutes" value the reassessment was asked +to weigh — and it only exists once `Compono.Options` is being built +anyway for Monitor/Snapshot (§7). It's real ergonomic value, but it's +value the future `Compono.Options` package's own design should capture +(offering a consistent way to get all three interfaces from one value), +not a separate justification for a standalone `IOptions` capability. + +**Conclusion (unchanged in substance, reframed in scope):** `IOptions` +composition/mocking *on its own* is an **acceptable Compono-native +alternative, already shipped** — no new capability, no ADR, no standalone +package or helper. Its ergonomic story materially improves only as part +of `Compono.Options`'s broader, coherent Monitor/Snapshot/`IOptions` +consistency story (§16, §18), not as an independent admission. The one +real friction point (wrapping an auto-composed `T`) is unchanged from the +original finding — it's the general, already-tracked ADR-0052 Finding B +(§14), not an Options-specific gap. + +--- + +## 6. `IOptionsSnapshot` — detailed analysis (Reassessed 2026-09-07) + +No dogfooding evidence exists anywhere in this repo's research history for +`IOptionsSnapshot` specifically (grep across `docs/research/*.md` and +`docs/roadmap/*.md` found none) — unlike `IOptions`, which has three +independent real-project sightings. + +Ordinary .NET testing practice for `IOptionsSnapshot` either (a) spins +up a real, minimal `ServiceCollection`/`ServiceProvider` and registers +`.Configure(...)`, which works but pulls a full DI container into a +unit test purely to get scope-shaped options — heavier than Compono's own +"compose only what's needed" philosophy — or (b) hand-writes a fake +identical in shape to an `IOptionsMonitor` fake, minus change +notification, since the only behavior a unit test actually exercises is +`Value`/`Get(name)`. + +**Reassessed — explicitly not on cost grounds.** The product owner +specifically flagged that "cheap to implement alongside Monitor" is not, +by itself, product justification, and that concern is correct: shared +implementation cost is a fact about engineering effort, not about whether +a consumer actually needs this. Re-examined on its own merits instead: + +`IOptionsSnapshot` is a real, common shape in production code +(anything written against scoped/per-request configuration — the +idiomatic choice for most ASP.NET Core request-scoped consumers, per +Microsoft's own guidance in §4). A SUT built that way, composed with +Compono, needs `IOptionsSnapshot` satisfied like any other dependency. +If `Compono.Options` ships `IOptionsMonitor` support only, a consumer +whose SUT happens to depend on `IOptionsSnapshot` instead gets no +first-class help at all — pushed right back to hand-rolling exactly the +kind of incomplete fake (§7) the whole package exists to prevent, for a +sibling interface a real SUT is just as likely to need. That's a +coherence gap in the *capability itself* (an Options-testing package that +only covers one of the two named-options-supporting interfaces), not a +convenience shortfall. + +**Conclusion:** `IOptionsSnapshot` earns its place in `Compono.Options` +because it completes a coherent capability real SUTs need addressed +together (`IOptions`/`IOptionsSnapshot`/`IOptionsMonitor`, all +needing to stay consistent for a given `T`, per §5's reframed finding) — +not because implementation cost happens to be low. Low shared cost remains +true and relevant to §15's cost analysis, but it is not, on its own, why +Snapshot belongs in scope. + +--- + +## 7. `IOptionsMonitor` — detailed analysis (the strongest candidate) + +### What a correct implementation has to preserve + +From `OptionsMonitor`'s actual constructor and behavior +(confirmed directly against +[dotnet/runtime's `OptionsMonitor.cs`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs)): + +```csharp +public OptionsMonitor( + IOptionsFactory factory, + IEnumerable> sources, + IOptionsMonitorCache cache) +``` + +- `CurrentValue => Get(Options.DefaultName)` — reading the default-name + value is just `Get` with a well-known name; no separate storage. +- **Change propagation**: when a watched `IChangeToken` fires, + `InvokeChanged` removes the affected name from the cache + (`_cache.TryRemove(name)`), recomputes it via `Get(name)`, and invokes + `_onChange?.Invoke(options, name)` — every subscriber, not just one. +- **`OnChange`**: registers a listener, returns an `IDisposable` that + unsubscribes *that specific listener* on `Dispose()` — multiple + independent subscribers are a first-class, expected scenario. +- **Named options**: `Get(name)` is looked up per name, independently + cached. +- **Disposal**: `Dispose()` unregisters every change-token subscription it + made — a real resource-cleanup contract, not a no-op. + +### What the community's actual answer gets wrong + +The most widely-cited pattern for testing `IOptionsMonitor` +([Testing IOptionsMonitor - Ben Foster](https://benfoster.io/blog/20200610-testing-ioptionsmonitor/), +and the same shape recurs across +[code-maze](https://code-maze.com/csharp-mock-ioptions/), +[thecodebuzz](https://thecodebuzz.com/unit-test-mock-ioption-net-core-ioption-moq-appsettings/), +and similar articles) is a hand-rolled class: + +```csharp +public class TestOptionsMonitor : IOptionsMonitor +{ + private Action _listener; + + public TestOptionsMonitor(TOptions currentValue) => CurrentValue = currentValue; + public TOptions CurrentValue { get; private set; } + public TOptions Get(string name) => CurrentValue; // ignores name entirely + public void Set(TOptions value) + { + CurrentValue = value; + _listener.Invoke(value, null); // single listener only + } + public IDisposable OnChange(Action listener) + { + _listener = listener; // overwrites any prior subscriber + return Mock.Of(); // fake disposable, no real unsubscribe + } +} +``` + +This is **not a faithful fake** — three concrete, documented gaps: + +1. **`Get(name)` ignores `name`** — named options are silently broken; a + system under test that distinguishes named configurations can't be + tested correctly at all. +2. **Single-listener only** — a second `OnChange` subscription silently + *replaces* the first rather than adding to it, unlike real + `OptionsMonitor`, which supports and invokes every subscriber. +3. **`OnChange`'s returned `IDisposable` doesn't unsubscribe anything** — + disposal is a no-op, so a test can't verify unsubscribe behavior, and a + production code path that disposes its subscription and expects no + further callbacks would pass against this fake while being broken + against the real thing. + +This is exactly the "misleading fake" risk the admission process's +investigation prompt asked to guard against — a naive implementation +*looks* like it solves the problem and is exactly what gets copy-pasted +project to project, while silently under-testing named options, +multi-subscriber behavior, and disposal. + +### Gate A's "meaningful abstraction" test, applied directly + +Step 3 asks: *could a consumer already write the equivalent correctly in a +few obvious lines?* The answer, demonstrated by real, widely-published +prior art above, is **no** — the "few obvious lines" version that gets +written in practice is measurably wrong in three independent ways. A +correct implementation needs a per-name value store, a real multi-listener +event with per-listener disposal, and thread-safety (the production type +is a singleton other singletons may call concurrently) — meaningfully more +machinery than the naive version, and machinery a consumer is unlikely to +get right unprompted. This clears "meaningful abstraction" on real, +sourced evidence, not assumption. + +### What must NOT be modeled + +Per the admission process's cost-proportionality principle and this +repo's own restraint (`design-principles.md`'s "avoid becoming... a +reflection-heavy runtime" / "a feature-complete wrapper"), a Compono +`IOptionsMonitor` test double should be a **deliberately narrower +testing abstraction**, not a reimplementation of the full production +pipeline: + +- No real `IConfiguration`/change-token wiring is needed — a test wants to + *deliberately* push a new value and see subscribers fire, not simulate + file-system polling or JSON reload. +- No `IOptionsFactory`/`IConfigureOptions` pipeline is needed — the + test already has (or composes) the exact `T` values it wants; there is + no "configure via delegate then materialize" step to replicate. +- Synchronous notification (matching real `OptionsMonitor`'s own + synchronous `_onChange?.Invoke(...)` call) is correct and sufficient — + no async notification model should be invented (this is also explicitly + out of scope per this investigation's exclusions). + +--- + +## 8. Named-options analysis + +Both `IOptionsSnapshot.Get(name)` and `IOptionsMonitor.Get(name)` +need real, independent per-name storage — this is the single most common +correctness gap in hand-rolled fakes (§7, gap 1). A correct Compono +implementation needs, at minimum, a name-keyed dictionary of current +values, with `Options.DefaultName` (`string.Empty`) as the well-known +default-name key `CurrentValue`/parameterless `Value` reads through — the +same semantic `IOptionsMonitorCache` already establishes in the real +implementation. This is not extra scope invented for completeness; it's +required to avoid becoming exactly the kind of "misleading fake" this +investigation was asked to guard against. + +--- + +## 9. Change/reload/`OnChange` analysis + +Real per-file-provider reload (JSON/INI/XML/user-secrets/KeyPerFile +sources watching the file system, per the Learn page's own list) is +explicitly out of scope for this investigation (excluded: "configuration-file +mocking," "environment-variable mocking") and, more importantly, is not +where the real friction lives — a unit test doesn't want to touch the file +system at all. The friction is in the *notification mechanism itself*: +giving a test a deterministic way to say "the value changed now, fire +every subscriber synchronously" without needing any real configuration +source, change token, or DI container. That's a Compono-native testing +primitive (a `.Set(newValue)`/`.Change(newValue)`-shaped method), not a +simulation of .NET's reload pipeline. + +--- + +## 10. Validation analysis + +`IValidateOptions`, `DataAnnotations`-based validation +(`ValidateDataAnnotations`), `ValidateOnStart`, and the compile-time +options-validation source generator +([Compile-time options validation source generation](https://learn.microsoft.com/en-us/dotnet/core/extensions/options-validation-generator)) +are all first-party, already source-generation-first where AOT matters, +and are a **production configuration-correctness concern**, not a test +composition/double concern — validation runs against real configuration +at startup or first access, and there's no Compono-specific value in +wrapping a validation call that already works identically against a +composed value. **Rejected** — not because the API is thin (`Compono.FakeItEasy`'s +reason), but because it fails "Compono-specific value" outright: it's not +a testing-friction problem at all. + +--- + +## 11. Configuration ↔ Options interaction + +Options binds to Configuration via `services.Configure(configuration.GetSection(...))` +— the only interaction point relevant to testing is that +`IOptionsMonitor`'s change notification is *driven by* a +`IConfigurationRoot.Reload()`-triggered change token when Options is wired +this way in production. Since §9 established that a Compono test double +should let a test push a new value directly rather than simulate +Configuration's reload pipeline, this interaction doesn't create any +additional capability requirement — a Compono `IOptionsMonitor` fake +that lets a test call `.Set(newValue)` directly is strictly simpler than, +and does not need to model, the Configuration-driven path real production +code uses. + +--- + +## 12. Compono core / `Compono.DependencyInjection` interaction + +Read directly rather than assumed from the package's name +(`src/Compono.DependencyInjection/ComponoServiceProvider.cs`, +`CompositionRowServiceProviderExtensions.cs`; ADR-0047): + +- **What it actually owns:** exactly one public member, + `CompositionRow.AsServiceProvider()`, returning an `IServiceProvider` + backed by `CompositionRow.TryResolveConfigured` — a **pull-only** + bridge over values a row can *already* resolve (row scope, exact + `Register` registrations, stage 4-6 value providers). It resolves + nothing itself; it forwards to the row and caches per-type identity. +- **What it explicitly does not do:** construct, compose, or own any new + value-production logic. It has no concept of `IOptions`, + `IOptionsMonitor`, named options, or change notification — nothing + about its actual code touches configuration or options at all. +- **Why Configuration/Options support does not belong here:** a hand-authored + `IOptionsMonitor` test double is production/composition logic — it + *creates* a new kind of composable value, the same category of work + `Compono.Http`'s `TestHttpHandler` or `Compono.Logging`'s + `CapturingLogger` do. `Compono.DependencyInjection`'s entire design is + the opposite: it never creates anything, it only forwards already-resolved + values through a different interface shape. Bolting options-construction + logic onto it would broaden a deliberately narrow, single-purpose bridge + package into something its own ADR (0047) never scoped it for — the + same "improperly broaden an existing package" failure mode Gate A's + package-boundary criterion exists to catch. +- **Core `Compono`:** provides `Match`/`CallVerifier` (already reused + by `Compono.Http`) and `ICompositionContext.Resolve()`. A new + `Compono.Options` package can reuse `Match`/`CallVerifier` the same + way `Compono.Http` does, without core ever knowing `Compono.Options` + exists — consistent with the core-knows-nothing-about-integrations rule. + +--- + +## 12a. Profiles and reusable Configuration/Options composition (New, 2026-09-07) + +Investigated directly against `ICompositionProfile`/`CompositionBuilder`'s +actual current code, per the reassessment request — not assumed. + +**A profile is just a named, reusable sequence of ordinary builder calls.** +`ICompositionProfile.Configure(CompositionBuilder builder)` (ADR-0018) has +no special vocabulary of its own — a profile *is* whatever builder calls +its `Configure` method makes: `UseNSubstitute()`, `UseBogus(...)`, +`Register(...)`, `Share()`, all identically, in any combination. +`CompositionBuilder.Register` (`src/Compono/CompositionBuilder.cs`) is +fully generic — `Register(Func)` and +`Register(Func)` — with nothing type-specific about `IConfiguration` +or `IOptions`. + +**Direct consequence: reusable Configuration/Options setup through +profiles is already fully possible today, with zero new capability.** A +profile can already do exactly this, unchanged, right now: + +```csharp +public sealed class AppTestProfile : CompositionProfile +{ + protected override void Configure(CompositionBuilder builder) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(DefaultTestValues) + .Build(); + + builder + .Register(() => configuration) + .Register>(() => Options.Create(new MyOptions { ... })); + } +} +``` + +This directly answers the reassessment's own question ("can existing +profiles already encapsulate the raw ceremony cleanly? if yes, is the +remaining friction merely documentation/discoverability?") — **yes, and +yes**, for both Configuration and standalone `IOptions`. Nothing about +profiles is currently a blocker; nothing about profiles needs new +capability to make Configuration or bare `IOptions` reusable. This +directly confirms and sharpens §3/§5's conclusions: the remaining friction +there really is discoverability (knowing the idiom exists), not a +composition-model gap — and discoverability alone, per Step 2/3 of the +admission process, is a documentation problem, not a package problem, for +those two. + +**Where profiles change the calculus: `Compono.Options`'s coherence +story.** A profile is exactly the right place to express "this test +suite's baseline options for `MyOptions`, consistent across `IOptions`/ +`IOptionsSnapshot`/`IOptionsMonitor`, with individual tests able to +override the value inline" — the same "express once, reuse across many +tests, vary selectively" shape ADR-0056's `Share()` was justified by, +applied to Options instead of arbitrary shared values. This doesn't +require any *new* profile mechanism (profiles already generically support +whatever `Compono.Options` ends up registering, the same way they support +`UseBogus`/`UseNSubstitute` today) — but it means `Compono.Options`'s +future design should treat "read naturally both inline and inside a +profile" as a real design constraint, not an afterthought, since that's +where its coherence value is most visible in practice. + +**Answering the reassessment's specific profile questions:** + +- *Can existing profiles already encapsulate the ceremony cleanly?* Yes, + for Configuration and standalone `IOptions` (confirmed above, + directly against `Register`'s actual signature). +- *If yes, is remaining friction merely documentation/discoverability?* + Yes, for those two — reinforcing §3/§5's documentation-only/no-new-capability + conclusions with direct evidence rather than assumption. +- *Would first-class APIs make profiles more expressive/less repetitive?* + Not for Configuration/bare `IOptions` — `Register` is already the + first-class API there, and a named wrapper adds a name, not expressivity. + For the Options-coherence capability (Monitor/Snapshot/`IOptions` + wired consistently), yes — a `Compono.Options` package earns exactly + this kind of profile-native expressiveness gain, the same way + `Share()` did for sharing. +- *Would first-class APIs establish consistent semantics every consumer + would otherwise reinvent?* Yes, specifically for the multi-interface + consistency risk (§5, §6) — that's the one place "everyone reinvents + this, slightly differently, and some get it subtly wrong" genuinely + applies here, mirroring `Compono.Http`'s own admission rationale + (independently-duplicated, subtly-inconsistent hand-rolled fakes). + +--- + +## 13. Gate A: criterion-by-criterion evaluation (Reassessed 2026-09-07) + +Applied to the coherent candidate that survives to this stage — consistent +`IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` composition for a +given `T`, correctly wired to one underlying value/store (§1, §5, §6): + +1. **Compono-specific value — clears, on two independent grounds.** (a) + Real, sourced friction (§7): the standard hand-rolled fake used across + the .NET community is demonstrably incomplete (silently broken named + options, single-listener only, no real disposal). (b) Composition + ergonomics in their own right, per the Reassessment above: keeping a + consumer's related Options interfaces consistently wired to one value, + expressible once and reusable via a profile (§12a), the same category + of value `Share()`/`Compono.Bogus` were admitted on. Ground (a) alone + already cleared this criterion in the original research; ground (b) + independently reinforces it and is why `IOptionsSnapshot` belongs in + scope on its own merits (§6), not as a cost-sharing add-on. +2. **Native ecosystem fit — clears.** The design vocabulary is + Microsoft's own (`CurrentValue`, `Get(name)`, `OnChange`, named + options) — nothing about this needs Compono-invented terminology, and + the investigation deliberately avoided hiding named options, + snapshot/monitor distinctions, or change semantics behind + Compono-specific naming. +3. **Meaningful abstraction — clears, on direct evidence.** §7 shows the + "few obvious lines" version that gets written in practice is + measurably wrong in three independent, documented ways. This is a + materially different bar than a trivial extension method. +4. **Architectural fit — clears, with one explicit prerequisite noted.** + A hand-written, non-generated runtime class (test constructs and + configures it directly, mirroring `TestHttpHandler`'s shape) needs no + reflection, no new core extension point, and no generator work — it is + buildable entirely on existing public surface (`Match`, + `CallVerifier`) the same way `Compono.Http` already is. **The one + explicitly-flagged exception**: *automatically* supplying + `IOptionsMonitor`/`IOptions` for an arbitrary composed `T` with + no manual registration is blocked on ADR-0052 Finding B (§14) — a + real, already-identified, currently-unresolved core architectural + question this candidate's own design must not invent an answer to ad + hoc. The clean scope for a v1 design is a class the test constructs + explicitly and hands an already-composed (or hand-built) `T` to — see + §14 for why this sidesteps the open gap entirely rather than working + around it. +5. **Package-boundary justification — clears, as its own package.** §12 + shows this doesn't belong in `Compono.DependencyInjection` (wrong + shape entirely) or core (depends on `Microsoft.Extensions.Options`, + which core must never reference). It's substantial enough — real + thread-safety, disposal, and named-value-store logic, not a one-line + helper — to justify its own package, the same bar `Compono.Http` + cleared with a comparable amount of hand-written logic. + +**Maintenance/CI/docs/skill cost** (weighing factor, not a sixth +pass/fail gate): comparable to `Compono.Http`'s — one new package guide, +one new skill reference file, one new CI package-validation target. Real +but bounded and linear, per the same finding ADR-0039 already made for +this repo's existing routing pattern. + +**`IOptions` (standing alone) and Configuration still do not reach Gate +A independently** — not because they fail a criterion, but because Step +1/Step 2 of the admission process (concrete problem; already solved today, +including *inside* Compono's composition model via profiles, per §12a) +resolve them before Gate A's five criteria are even reached. This is a +reassessed conclusion, not a carried-over one: §3/§5/§12a explicitly +re-ran both under the sharper "stays inside the composition model" +question and confirmed this holds, rather than re-asserting the original +raw-difficulty framing. + +--- + +## 14. The ADR-0052 Finding B prerequisite, in detail + +ADR-0052's own text is explicit that Finding B — "a type reachable only +via nested `context.Resolve()` inside a registration factory" — is +"the separate, still-open question, untouched by Part B," and that Part A +(the mechanism that would have covered a related but distinct case, +statically-recognized `Register(...)` calls) was itself deferred +("Recommendation: ship Part B alone for now. Defer Part A... as a +separate, later" decision). As of this research, Finding B has **no +accepted resolution**. + +This matters directly for Configuration/Options: the real +`alexa-vox-craft` friction that surfaced Finding B in the first place +(§13.4 of `docs/research/0010-...`) was **exactly** this pattern: + +```csharp +.Register>(context => + Options.Create(context.Resolve())); +``` + +A hypothetical automatic stage-4-6 provider that composes `IOptions`/ +`IOptionsMonitor` for *any* requested `T` by calling +`context.Resolve()` internally would hit the identical wall whenever +`T` isn't independently discovered as a root elsewhere — it would not be +a new problem, but the same open one, narrowed to Options types. + +**Why the recommended `Compono.Options` design avoids this entirely, +without waiting on Finding B's resolution:** if the test constructs the +fake directly and hands it an already-composed value — + +```csharp +var options = composer.Create(); // ordinary root-level composition +var monitor = new TestOptionsMonitor(options); // illustrative only, not a proposed API +``` + +— then no nested, provider-internal `context.Resolve()` ever happens. +`MyOptions` is composed the ordinary way, as a normal root or parameter, +which the generator already discovers correctly today. This is not a +workaround invented to dodge Finding B — it's the same shape +`Compono.Http`'s `TestHttpHandler` already uses (the test constructs and +configures it directly; nothing auto-resolves it into existence). **An +automatic, no-registration-needed version remains blocked on Finding B** +and is correctly scored as documentation-only/pending, not designed +around here. + +--- + +## 15. Cost analysis + +Relative to `Compono.Http`'s already-accepted cost (the closest real +precedent — a new, hand-written, non-generated runtime package): + +| Cost dimension | Estimate | +|---|---| +| Public API surface | Comparable to `Compono.Http` — a handful of public types (`TestOptionsMonitor`, likely a shared snapshot type or shared base), no generic explosion | +| Dependencies | `Microsoft.Extensions.Options` only (a near-universal transitive dependency already present in any project using the pattern this package tests) — no heavier than `Compono.Http`'s `Microsoft.Extensions.Http`-free design | +| Generator changes | None — no source generation involved, matching `Compono.Http` | +| Runtime machinery/allocations | A name-keyed dictionary and a multi-subscriber event list per instance — small, bounded, comparable to `TestHttpHandler`'s registration list | +| Concurrency/lifecycle complexity | Real, and the crux of doing this correctly (§7) — thread-safe multi-subscriber notification and per-subscriber disposal are non-trivial but well-understood (the real `OptionsMonitor` source is the reference implementation to match against) | +| Native AOT/trimming | Clean — no reflection, no dynamic code, same posture as every other Compono package | +| Documentation | One new package guide, one new Concept/Cookbook entry — comparable to `Compono.Http`/`Compono.Logging`'s launch cost | +| Skill/eval maintenance | One new package reference file in the `compono` agent skill's detection table — linear, per ADR-0039's own finding | +| Compatibility commitments | A public contract for `IOptionsMonitor`/`IOptionsSnapshot` test-double behavior, once shipped, is a real long-term API surface — same commitment level as any other shipped package | + +**Overall: comparable to `Compono.Http`**, not a larger undertaking — the +evidence bar this candidate needed to clear (§7's documented, +citation-backed correctness gaps in existing practice) is proportionally +strong for a cost this size. + +--- + +## 16. Capability-slice classification (Reassessed 2026-09-07) + +Using ADR-0029's five-way classification, applied per slice rather than +once: + +- **Consistent `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` + composition for a given `T`** — clears Gate A (§13) and Gate B (§19, + explicit product-owner request) → **Roadmap item.** (Reassessed: + previously scoped as "Monitor/Snapshot," now scoped as the coherent + three-interface capability per §1/§5/§6/§12a.) +- **`IOptions` composition/mocking, standing entirely alone (no + Monitor/Snapshot need in the same SUT)** — **Acceptable Compono-native + alternative, already shipped.** No new ADR/Amendment needed; the + existing pattern (`Compono.TestDoubles`'s generated double, plus + `Register>(() => Options.Create(...))` for the composed-value + case, both already reusable through profiles with zero new capability + per §12a) is already pleasant and already documented in the migration + guide precedent research (§5). +- **Automatic no-registration `IOptions`/`IOptionsMonitor` + composition for arbitrary `T`** — **Documentation-only, pending a + prerequisite core decision** (ADR-0052 Finding B). Recorded here, not + designed around — the correct future path is Finding B getting its own + resolution first, the same restraint ADR-0039 already applied to + `Compono.DependencyInjection`'s "richer" idea pending its own + prerequisite core concept. +- **`IConfiguration`/`ConfigurationBuilder` composition** — + **Documentation-only.** Reassessed under the sharper ergonomics question + (§3, §12a) and confirmed, not merely re-asserted: no capability gap, + including inside profiles; a Cookbook recipe is the correct and + sufficient artifact. +- **Options validation (`IValidateOptions`, DataAnnotations, the + validation source generator)** — **Rejected.** Not a testing/composition + problem; a production concern .NET already solves, including with its + own source generator where AOT matters. + +--- + +## 17. Package-boundary analysis (Reassessed 2026-09-07 — conclusion unchanged, reasoning strengthened) + +Working down the admission process's placement ladder (§"Step 6"): + +1. **Core `Compono`** — ruled out. Core must never reference + `Microsoft.Extensions.Options`, matching the same rule that keeps every + other integration (`Compono.NSubstitute`, `Compono.Bogus`, + `Compono.Http`, `Compono.Logging`) out of core. +2. **An existing extension package** — ruled out for + `Compono.DependencyInjection` specifically (§12: wrong shape, pull-only + forwarding vs. new construction logic). No other existing package's + ecosystem overlaps (`Compono.Http` is HTTP-specific; `Compono.Logging` + is `ILogger`-specific). +3. **A new extension package — `Compono.Options`.** Justified on two + independent grounds now, not one: the correctness work (§7) remains + substantial enough to be its own independently-consumed artifact, + matching `Compono.Http`'s precedent almost exactly in shape and scale + — **and** the package is the right home for the multi-interface + consistency value identified in §5/§6/§12a (a consumer getting + `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` from one + coherently-wired source). Combining a package and its own ergonomic + front door is exactly the shape ADR-0056's `Share()` and + `Compono.Bogus` already established. **`Compono.Configuration` should + still not be created** — reassessed, not re-asserted (§3): nothing in + this investigation, including under the sharper ergonomics question, + justifies it as a package; the one real Configuration-adjacent idea is + fully satisfied by a documentation recipe. +4. **Documentation/sample guidance only** — the correct outcome for both + `IConfiguration`/`ConfigurationBuilder` composition (§3) and the + automatic no-registration Options composition idea (§14), pending its + own prerequisite. + +**Recommended package identity:** + +``` +Compono.Options + -> Compono (reuses Match, CallVerifier if verification is added — no generator dependency) +``` + +matching `Compono.Http`'s own dependency graph shape exactly. + +--- + +## 18. Recommended admission outcome (Reassessed 2026-09-07) + +- **`Compono.Options`** — **Roadmap item.** Scoped as the coherent + `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` consistency + capability (§1), not Monitor/Snapshot alone. Gate A cleared (§13), Gate B + cleared by the clarified explicit product-owner request (§19). Needs its + own problem-focused `Proposed` ADR next (per `tasks/design.md`), not + designed here — the problem statement that ADR should open with is + restated precisely in item 17 of the Final report below. +- **`IOptions` composition/mocking** — **No action needed.** Already an + acceptable Compono-native alternative; worth a Cookbook entry + cross-referencing `Compono.TestDoubles`'s existing `IOptions` support + once `Compono.Options` ships, so a reader lands on the right answer for + each of the two related-but-distinct problems. +- **`Compono.Configuration`** — **Rejected as a package; documentation-only + as an idea.** Record in a future `future-packages.md` update (deferred + to the actual design/roadmap step, per this investigation's scope + limits) as a Cookbook recipe candidate, not a package candidate. +- **Options validation** — **Rejected outright.** Not recorded as a + documentation-only idea even — it's out of scope on "Compono-specific + value" grounds, not a near-miss. + +--- + +## 19. Gate B evaluation (Reassessed 2026-09-07) + +Per `docs/architecture/capability-admission.md` Step 4, Gate B accepts +"an explicit product-owner request" as legitimate evidence on its own — +the same mechanism that already cleared `Compono.TUnit`, `Compono.NUnit`, +and Compono-owned source-generated test doubles, none of which had +dogfooding evidence at admission time either. + +**The request itself is now more precise** (2026-09-07): "I want Compono +to make common .NET Configuration and Options dependencies easier and more +discoverable to compose in tests, provided the resulting capability is +coherent, composition-native, architecturally sound, and meaningfully +better than simply documenting Microsoft's APIs" — explicitly conditioned +on Gate A clearing independently, exactly as the original request was. + +**Applied here:** Gate B is satisfied for `Compono.Options`, scoped as the +coherent three-interface consistency capability (§1, §13). It is **not** +separately claimed for `Compono.Configuration` (reassessed and still no +Gate A clearance to apply it to, §3) or for the automatic-composition idea +(still blocked at Gate A's architectural-fit criterion, pending Finding +B, §14) — the clarified request does not change either verdict, because +neither cleared Gate A in the first place, and Gate B evidence cannot +substitute for a Gate A criterion that didn't clear (per +`capability-admission.md`'s own ordering: both gates must clear, in that +order). + +**Where future dogfooding would still be valuable**, despite Gate B +already being satisfied: real dogfooding against `alexa-vox-craft` or +`cosmere-tracker` (both already have real `IOptions` usage, per §5) +would validate the *design* once a `Compono.Options` ADR exists — in +particular, whether the recommended "test constructs and hands over an +already-composed value" shape (§14) actually reads well against a real +consumer's test suite, and whether `IOptionsSnapshot` bundled in ends +up used at all in practice. This is design validation, not admission +evidence — Gate B is already closed. + +--- + +## 20. Rejected/deferred ideas + +- **`Compono.Configuration`** — rejected as a package (§17); the + underlying idea survives only as a documentation-only Cookbook + candidate. +- **Options validation support** — rejected outright (§10, §16); not a + testing/composition concern. +- **Automatic, no-registration `IOptions`/`IOptionsMonitor` + composition for arbitrary `T`** — deferred, pending ADR-0052 Finding B's + own resolution (§14, §16). Not designed around; not silently dropped — + recorded here with its exact blocker so it isn't rediscovered from + scratch. +- **Simulating real Configuration-driven reload** (file-system change + tokens, `IConfigurationRoot.Reload()`) inside a Compono test double — + considered and rejected in favor of a direct `.Set(...)`-shaped push, + per §9/§11 — modeling the full production reload pipeline inside a test + double would be exactly the "reflection-heavy, feature-complete + wrapper" `design-principles.md` warns against, for a scenario a + deterministic direct push already serves better. + +--- + +## 21. Open questions genuinely requiring design work (not more research) (Reassessed 2026-09-07 — two questions added) + +These belong in the `Compono.Options` ADR's own design pass, per +`tasks/design.md`'s deep-dive process — this document deliberately does +not answer them: + +- **(New)** What the single, coherent entry point for "this `T`'s Options + composition" looks like — one call that consistently wires + `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` to the same + underlying value, versus three separate registrations a consumer must + remember to keep in sync themselves. This is the design question the + reassessment's coherence finding (§5, §6) most directly feeds. +- **(New)** How this reads both inline and inside a profile (§12a) — a + profile can already hold whatever this design produces with zero new + profile-mechanism work, but the design pass should still verify the + resulting shape is genuinely pleasant in both contexts, not just + inline, since profile reuse is where §12a found the strongest + ergonomic payoff. +- Exact public shape: a single `TestOptionsMonitor` class implementing + both `IOptionsMonitor` and `IOptionsSnapshot`, or two related + types? (§6 found their implementations nearly identical, but that's an + implementation observation, not a public-API decision.) +- How a test supplies/changes named-option values — a dictionary-style + indexer, a builder, or named overloads of a `.Set(...)`-shaped method? +- Whether/how this composes automatically as a constructor parameter via + `[Shared]`/registration for the common case, versus always being + constructed explicitly by the test (§14's recommended default) — and if + automatic composition is offered at all pre-Finding-B, what its exact, + narrower boundary is (e.g., only when `T` is already independently + composed elsewhere, detectable or not). +- Whether `CallVerifier` reuse (verifying an `OnChange` subscription fired + N times) is in v1 scope or a later addition, mirroring + `Compono.Http`/`Compono.TestDoubles`'s own verification surface. +- Thread-safety implementation approach (lock-based like + `Compono.DependencyInjection`'s adapter, or a lock-free + `ConcurrentDictionary`-based approach like the real + `OptionsMonitorCache`). +- Whether disposal of the fake itself needs to be modeled (real + `OptionsMonitor.Dispose()` unregisters change-token subscriptions; + a Compono fake with no real change tokens may not need an equivalent + disposal contract at all — needs an explicit decision, not a default). + +--- + +## 22. Sources and experiment results + +**Primary sources fetched directly, not recalled from memory:** + +- [Options pattern - .NET | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/core/extensions/options) + (updated 2025-10-22, refreshed page metadata 2026-05-15) — current + `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor`/`IOptionsFactory`/ + `IOptionsMonitorCache`/`IOptionsChangeTokenSource` semantics, named + options, validation (`IValidateOptions`, DataAnnotations, + `ValidateOnStart`, `[ValidateObjectMembers]`/`[ValidateEnumeratedItems]`). +- [dotnet/runtime `OptionsMonitor.cs`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs) — + constructor signature, `CurrentValue`/`Get`/`OnChange`/`InvokeChanged`/ + `Dispose` implementation, confirming §7's behavioral claims directly + against source rather than documentation prose. +- [Compile-time configuration source generation - .NET | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration-generator) — + `EnableConfigurationBindingGenerator`, automatic activation under + `PublishAot`, confirming §3's "already source-generation-first" finding. +- [Testing IOptionsMonitor - Ben Foster](https://benfoster.io/blog/20200610-testing-ioptionsmonitor/) — + the widely-cited hand-rolled `TestOptionsMonitor` pattern quoted + verbatim in §7, including the article's own caveat about incomplete + disposal semantics. +- Corroborating hand-rolled-fake pattern sightings: + [code-maze](https://code-maze.com/csharp-mock-ioptions/), + [thecodebuzz](https://thecodebuzz.com/unit-test-mock-ioption-net-core-ioption-moq-appsettings/), + [mitch.codes](https://mitch.codes/net-core-manually-instantiating-ioptions-for-unit-testing/) — + used only to confirm the pattern recurs across independent sources, not + quoted individually. + +**Repository sources inspected directly (not assumed from names):** + +- `src/Compono.DependencyInjection/ComponoServiceProvider.cs`, + `CompositionRowServiceProviderExtensions.cs` — confirmed the + pull-only, no-construction-logic shape described in §12. +- `src/Compono.Logging/CompositionBuilderExtensions.cs`, + `LoggingProvider.cs` — the stage-6 `ICompositionValueProvider` shape, + used as a precedent for what an *automatic* provider would look like + (and why that shape hits Finding B for Options specifically, §14). +- `src/Compono.Http/TestHttpHandler.cs` — the hand-written, + directly-constructed, non-generated runtime-class precedent §7/§14's + recommended `Compono.Options` shape follows. +- `src/Compono/ICompositionContext.cs` — confirmed `Resolve()`'s + actual contract (only valid inside an active registration/provider + invocation), relevant to why Finding B's nested-resolve gap is a real + constraint and not a hypothetical one. +- `docs/adr/0052-compile-time-composition-discovery-boundary-for-registered-and-nested-resolved-types.md` — + confirmed Finding B's status as still-open/untouched by Part B directly + from the ADR's own text, not inferred from `docs/roadmap/post-mvp.md`'s + summary alone. +- `docs/research/0001-autofixture-comparison.md`, + `0004-lightsaber-skill-testdoubles-v2-dogfood.md`, + `0005-lightsaber-skill-testdoubles-v2-third-dogfood.md`, + `0010-alexa-vox-craft-compono-ecosystem-migration.md`, + `0011-alexa-vox-craft-mediatr-tests-testkit-migration-slice-1.md` — every + real `IOptions` sighting cited in §5, including the exact Finding B + reproduction in §14. + +**Additional sources inspected for the 2026-09-07 reassessment:** + +- `docs/adr/0056-composition-builder-share-graph-wide-sharing.md`'s + Context — confirmed directly that `Share()` was admitted despite + `[Shared]` already making sharing fully possible, on ergonomics/ + composition-configuration grounds, the precedent cited in the + Reassessment section and §13. +- `docs/adr/0027-compono-bogus-package-design.md`'s Context — confirmed + `Compono.Bogus`'s own admission framing ("just call `UseBogus()`"), + cited as the second precedent for ergonomics-as-value independent of + raw difficulty. +- `docs/adr/0018-composition-profiles.md` and + `src/Compono/CompositionBuilder.cs` (`Register`'s two generic + overloads) — confirmed directly, not assumed, that profiles already + generically support any builder-level registration with no + Configuration/Options-specific capability gap (§12a). + +**No focused code experiments were run.** Every behavioral claim about +`OptionsMonitor` (§7) was verifiable directly against dotnet/runtime's +own source, and every claim about Compono's existing behavior (§5, §12, +§14) was verifiable directly against this repo's own source and prior +dogfooding research — neither required a disposable spike to confirm. If +the future `Compono.Options` ADR's design pass needs empirical +confirmation of a specific concurrency scenario (e.g., two threads calling +`OnChange`/`.Set(...)` concurrently against a candidate implementation), +that belongs in that design pass, per §21. diff --git a/docs/roadmap/future-packages.md b/docs/roadmap/future-packages.md index e25c1f8f..85b850f0 100644 --- a/docs/roadmap/future-packages.md +++ b/docs/roadmap/future-packages.md @@ -59,6 +59,9 @@ dogfooding evidence), the same mechanism that already gated ## Admission model +See [Capability & Package Admission](../architecture/capability-admission.md) +for the full, standalone process this page's dispositions are decided +against — this section is a short summary, not the operational reference. [ADR-0039](../adr/0039-future-extension-package-admission-gate-and-release-sequence.md) records a two-stage admission model for everything on this page: @@ -84,16 +87,26 @@ roadmap content. Compono-owned source-generated test doubles made the same full progression, shipping as `Compono.TestDoubles` once [PLAN-0043](../plans/0043-compono-generated-test-doubles.md) completed — see [`Compono.TestDoubles`](../packages/compono-testdoubles.md), also not -roadmap content anymore. No candidate currently sits at roadmap-item -status — `Compono.NUnit` made the same full progression and graduated -too, see above. +roadmap content anymore. `Compono.NUnit` made the same full progression and +graduated too, see above. `Compono.Options` — cleared Gate A and Gate B +([RESEARCH-0028](../research/0028-compono-options-configuration-admission-research.md), +reassessed 2026-09-07) via a dedicated admission research doc (like +`Compono.Http`/`Compono.DependencyInjection`, not this page's own candidate +pipeline), triggered by an explicit product-owner request and reassessed +once against Compono's own composition-ergonomics precedent (`Share()`, +`Compono.Bogus`) before Gate A was confirmed — graduated from this page's +roadmap once [PLAN-0064](../plans/0064-compono-options-testing-support.md)'s +implementation completed against +[ADR-0061](../adr/0061-compono-options-testing-support.md) (`Accepted` +2026-09-08) — see [`Compono.Options`](../packages/compono-options.md) for +what it ships. A dedicated `Compono.Configuration` package was explicitly +evaluated and rejected (RESEARCH-0028 §3/§17) — see "Documentation-only +ideas" below for the Cookbook deliverable that survives instead. No +candidate currently sits at roadmap-item status. ## Roadmap items (cleared Gate A and Gate B) -None currently. `Compono.TUnit`, Compono-owned source-generated test -doubles, and `Compono.NUnit` were the three candidates to reach this -status — see the Admission model note above; all three shipped as -packages and moved to [Package Guides](../packages/index.md). +None currently. ## Admitted candidates (cleared Gate A, no evidence yet) @@ -134,6 +147,25 @@ None currently. runtime-reflection question tracked in [Source Generation](../architecture/current/source-generation.md) — unchanged by ADR-0039, not evaluated against Gate A here. +- **Configuration composition (`IConfiguration`/`ConfigurationBuilder`) — + documentation-only, not a package.** Evaluated alongside + `Compono.Options` (RESEARCH-0028 §3/§12a) and explicitly rejected as a + package, including under the sharper composition-ergonomics question + that admitted `Compono.Options` itself — `Register(...)` + already keeps a consumer fully inside Compono's composition model, with + no multi-interface consistency risk analogous to Options. **Required + Cookbook deliverable, tracked against [ADR-0061](../adr/0061-compono-options-testing-support.md):** + basic in-memory `IConfiguration` composition, layered/override + configuration, reusable configuration through a profile, and the + routing guidance distinguishing "use ordinary Configuration + + `Register`" from "use `Compono.Options` for the + Options interfaces it owns" — explicitly not implying a + `Compono.Configuration` package exists. This documentation survives as + a defined deliverable of the `Compono.Options` effort even though no + package will be created for it. **Fulfilled** — see + [Compose Configuration From an In-Memory Collection](../cookbook/compose-configuration-from-an-in-memory-collection.md), + [Layer Configuration Overrides in a Test](../cookbook/layer-configuration-overrides-in-a-test.md), + and [Reuse Configuration Through a Profile](../cookbook/reuse-configuration-through-a-profile.md). ## Deferred indefinitely @@ -148,18 +180,22 @@ None currently. ## No committed sequence -ADR-0039 records no candidate order. `Compono.TUnit`, -the source-generated-test-doubles capability, and now `Compono.NUnit` all -cleared Gate B through an explicit product-owner request, not dogfooding -evidence — the two real dogfooding passes recorded in -[Post-MVP](post-mvp.md) still haven't produced a roadmap candidate of -their own in this space. No admitted candidates currently remain on this -page. If a future candidate clears Gate B around the same time as another -still-open one, ADR-0039's non-binding heuristics (value relative to -maintenance cost; architectural-validation diversity over repeating an -already-proven pattern) apply — category completion (finishing all -test-framework integrations before starting a test-double one, or vice -versa) is explicitly rejected as a sequencing principle. +ADR-0039 records no candidate order. `Compono.TUnit`, the +source-generated-test-doubles capability, `Compono.NUnit`, and now +`Compono.Options` all cleared Gate B through an explicit product-owner +request, not dogfooding evidence — the two real ADR-0029 dogfooding +passes recorded in [Post-MVP](post-mvp.md) still haven't produced a +roadmap candidate of their own in this space; `Compono.Options` reached +this page through the same admission-research path as `Compono.Http` +and `Compono.DependencyInjection`, not this page's own candidate +pipeline. No admitted candidates currently remain on this page — one +roadmap item (`Compono.Options`) does, tracked above. If a future +candidate clears Gate B around the same time as another still-open one, +ADR-0039's non-binding heuristics (value relative to maintenance cost; +architectural-validation diversity over repeating an already-proven +pattern) apply — category completion (finishing all test-framework +integrations before starting a test-double one, or vice versa) is +explicitly rejected as a sequencing principle. Any admitted candidate becomes real roadmap content the moment real demand and a concrete design exist — see [Post-MVP](post-mvp.md) for the diff --git a/docs/roadmap/index.md b/docs/roadmap/index.md index 5afd10f2..eeb6a696 100644 --- a/docs/roadmap/index.md +++ b/docs/roadmap/index.md @@ -26,7 +26,12 @@ today. own candidate list; it came from a dedicated admission research doc triggered by a real `alexa-vox-craft` dogfooding need — see [RESEARCH-0009](../research/0009-compono-http-admission-research.md). - If a capability isn't documented in + [`Compono.Options`](../packages/compono-options.md) + ([ADR-0061](../adr/0061-compono-options-testing-support.md)) reached + this shipped state the same way, via a dedicated admission research doc + ([RESEARCH-0028](../research/0028-compono-options-configuration-admission-research.md)) + triggered by an explicit product-owner request rather than this page's + own candidate pipeline. If a capability isn't documented in [Concepts](../concepts/index.md), [How-to Guides](../how-to/index.md), or a [Package Guide](../packages/index.md), it isn't available yet — see below for where it might be headed. diff --git a/docs/roadmap/proposed-adrs.md b/docs/roadmap/proposed-adrs.md index e96b8b15..9dd9d658 100644 --- a/docs/roadmap/proposed-adrs.md +++ b/docs/roadmap/proposed-adrs.md @@ -3,7 +3,21 @@ A status-filtered view of [`docs/adr/README.md`](../adr/README.md): every ADR that's `Proposed`, or `Accepted` but not yet implemented. -## Current state: none proposed or pending implementation +## Current state: none pending + +[ADR-0061](../adr/0061-compono-options-testing-support.md) (`Compono.Options`: +first-class Configuration/Options testing support) — `Accepted` +(2026-09-08), cleared Gate A and Gate B per +[RESEARCH-0028](../research/0028-compono-options-configuration-admission-research.md) — +was this page's last entry. Fully implemented by +[PLAN-0064](../plans/0064-compono-options-testing-support.md) (`Done`), +including code, tests (unit/AOT-smoke/dogfooding), docs +(`docs/packages/compono-options.md`, the Configuration Cookbook), and +`skills/compono` (`SKILL.md`, `references/options.md`, `evals/evals.json`, +plus the mandatory baseline-vs-updated skill-eval comparison), so it's +removed from this page per its own "entries removed once implemented" +rule — see [`Compono.Options`](../packages/compono-options.md) for what it +ships. [ADR-0044 Amendment 22](../adr/0044-compono-testdoubles-v2-overloads-generics-verification.md#amendment-22-2026-09-06-callverifieratleastintatmostint-added-requirement-3s-minimality-preserved-not-reversed) (`CallVerifier.AtLeast(int)`/`AtMost(int)`, plus the `Compono.Logging` diff --git a/skills/compono/SKILL.md b/skills/compono/SKILL.md index 210ee1a9..abea9837 100644 --- a/skills/compono/SKILL.md +++ b/skills/compono/SKILL.md @@ -5,7 +5,7 @@ description: >- tests. Compono is a source-generated AutoFixture alternative (`composer.Create()`/`CreateMany()`, `[Composable]`, registrations, profiles, `[Shared]`, plus optional - `Compono.XunitV3`/`Compono.TUnit`/`Compono.MSTest`/`Compono.NUnit`/`Compono.NSubstitute`/`Compono.Bogus`/`Compono.TestDoubles`/`Compono.DependencyInjection`/`Compono.Http`/`Compono.Logging` + `Compono.XunitV3`/`Compono.TUnit`/`Compono.MSTest`/`Compono.NUnit`/`Compono.NSubstitute`/`Compono.Bogus`/`Compono.TestDoubles`/`Compono.DependencyInjection`/`Compono.Http`/`Compono.Logging`/`Compono.Options` packages). USE FOR: writing/modifying/reviewing Compono tests, diagnosing `CMP0001`-`CMP0013` (errors), `CMP0020`-`CMP0032` and `CMP0035`-`CMP0037` @@ -20,7 +20,7 @@ description: >- with no Compono package referenced; generic reflection/DI questions; production object construction. SCOPES TO: only load - `xunit-v3.md`/`tunit.md`/`mstest.md`/`nunit.md`/`nsubstitute.md`/`bogus.md`/`testdoubles.md`/`dependencyinjection.md`/`http.md`/`logging.md` + `xunit-v3.md`/`tunit.md`/`mstest.md`/`nunit.md`/`nsubstitute.md`/`bogus.md`/`testdoubles.md`/`dependencyinjection.md`/`http.md`/`logging.md`/`options.md` references when that package is referenced or requested. license: MIT metadata: @@ -57,6 +57,7 @@ some packages and not others. | `` compose via `UseLogging()`, `CapturingLogger`/`CapturingLogger`, `Verify()` available — load `references/logging.md`. Generation is on by default once the package is referenced — never suggest a manual MSBuild opt-in step | +| ``/`UseOptions()` available — load `references/options.md` | | `Composer.Create(`, `.Create<`, `.CreateMany<`, `CompositionBuilder` | `*.cs` | High | Core Compono API in active use | | `[Compose]`, `[Compose<...>]`, `[Shared]` | `*.cs` | High | `Compono.XunitV3`, `Compono.TUnit`, `Compono.MSTest`, or `Compono.NUnit` attributes in active use - check which package is referenced before assuming which | | `ICompositionProfile` implementations | `*.cs` | Medium | Profile-based configuration convention already established — follow it rather than inventing a new one | @@ -208,6 +209,13 @@ matcher. Call-order verification remains unsupported by either. `Compono.Bogus`'s member-name conventions or `UseBogus(...)`, if that package is referenced. Don't reach for Bogus everywhere — plain generated values are fine when realism doesn't matter to the test. + - A composed type takes `IOptions`/`IOptionsSnapshot`/ + `IOptionsMonitor` for a settings type → `Compono.Options`'s + `TestOptionsSource` + `UseOptions()`, if that package is + referenced — see `references/options.md`. Don't reach for this when + the composed type depends on plain `IConfiguration` directly instead + — that's ordinary `ConfigurationBuilder`/`Register` + composition (Configuration Cookbook), no dedicated package involved. - Cross-test/cross-project reusable setup → an `ICompositionProfile`, not a copy-pasted builder lambda in every test. - A value only known at a *specific test's call site* that must @@ -337,8 +345,8 @@ undermines the reason Compono exists in this project. hasn't shipped — but distinguish "no dedicated package" from "no capability."** Only `Compono`, `Compono.XunitV3`, `Compono.TUnit`, `Compono.MSTest`, `Compono.NSubstitute`, `Compono.Bogus`, - `Compono.TestDoubles`, `Compono.DependencyInjection`, `Compono.Http`, and - `Compono.Logging` ship as packages today + `Compono.TestDoubles`, `Compono.DependencyInjection`, `Compono.Http`, + `Compono.Logging`, and `Compono.Options` ship as packages today (`Compono.TUnit` ships the full attribute family — `[Compose]`/`[Compose]`/`[Compose]`/`[Shared]`, @@ -368,7 +376,11 @@ undermines the reason Compono exists in this project. requires `NUnit` `[3.14.0, 5.0.0)` (one package covers the whole range — no `Compono.NUnit3`/`Compono.NUnit4`/`Compono.NUnit5` split; NUnit 5 stays prerelease-only and outside the supported contract until - it ships stable), see `references/nunit.md`) + it ships stable), see `references/nunit.md`; + `Compono.Options` ships `TestOptionsSource`/`UseOptions()` — a + coherent `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` source, + not a plain `IConfiguration` package, and there is no + `Compono.Configuration`, see `references/options.md`) — there is no `Compono.FakeItEasy` or `Compono.Moq`, and never invent a plausible-looking API for one. That doesn't always mean the underlying capability is unsupported, though: @@ -460,4 +472,5 @@ Load only what the Detection table says is relevant to the current task. | `references/dependencyinjection.md` | `Compono.DependencyInjection` is referenced or `.AsServiceProvider()` is called — `row.AsServiceProvider()`, its stable-identity/caching contract, and what it deliberately can't resolve | | `references/http.md` | `Compono.Http` is referenced — `TestHttpHandler`/matching/verification/lifetime work | | `references/logging.md` | `Compono.Logging` is referenced or `UseLogging()` is called — `ILogger`/`ILogger` composition, `CapturingLogger`/`CapturingLogger`, structured properties, scope semantics, `Verify()`, and the `ComponoGeneratedLogging` default-on/opt-out behavior | +| `references/options.md` | `Compono.Options` is referenced or `UseOptions(` is called — `TestOptionsSource`/`UseOptions()` identity model, named options, the unconfigured-name diagnostic, `IConfiguration`-vs-`Compono.Options` routing, and the Finding B boundary | | `references/patterns-and-antipatterns.md` | Reviewing existing Compono usage for correctness, migrating from AutoFixture, or unsure whether an approach is idiomatic | diff --git a/skills/compono/evals/evals.json b/skills/compono/evals/evals.json index 5a686c31..6ea85e43 100644 --- a/skills/compono/evals/evals.json +++ b/skills/compono/evals/evals.json @@ -644,6 +644,45 @@ "Points to Verify()'s existing overload-safe argument-matching surface for what IS supported", "Suggests Compono.NSubstitute or a project-local fake as the real answer if capture on the overloaded member is required" ] + }, + { + "id": 53, + "category": "routing", + "prompt": "My composed SkillService takes IOptionsMonitor and IOptions as constructor dependencies. Compono.Options is referenced. How should I compose these in a test?", + "expected_output": "Construct a TestOptionsSource directly with the initial value, then wire it with builder.UseOptions(source) - one call coherently satisfies both IOptions and IOptionsMonitor (plus IOptionsSnapshot) from that one source. Does not recommend two separate hand-wired registrations, and does not recommend a hand-rolled IOptionsMonitor fake.", + "files": [], + "expectations": [ + "Recommends TestOptionsSource constructed directly, not a hand-rolled IOptionsMonitor fake", + "Recommends the single UseOptions(source) wiring call, not separate Register>/Register> calls", + "States that IOptions stays frozen after the source's value later changes, while IOptionsMonitor reflects the current value", + "Does not claim IOptions has a Get(name) named-lookup surface" + ] + }, + { + "id": 54, + "category": "routing", + "prompt": "My composed type takes a plain IConfiguration dependency (not IOptions). Compono.Options is referenced in this project. Should I use Compono.Options for this?", + "expected_output": "No - Compono.Options only satisfies IOptions/IOptionsSnapshot/IOptionsMonitor, not plain IConfiguration. For IConfiguration itself, use ordinary ConfigurationBuilder/AddInMemoryCollection/Register composition (the Configuration Cookbook recipes) - there is no Compono.Configuration package, and none should be invented.", + "files": [], + "expectations": [ + "States that Compono.Options does not satisfy plain IConfiguration dependencies", + "Recommends ordinary ConfigurationBuilder/AddInMemoryCollection/Register composition instead", + "States plainly that there is no Compono.Configuration package", + "Does not fabricate a Compono.Configuration package or an IConfiguration-specific API on Compono.Options" + ] + }, + { + "id": 55, + "category": "behavioral-correctness", + "prompt": "My test uses Compono.Options. It calls monitor.Get(\"secondary\") but I never called source.Change(\"secondary\", ...) anywhere - the test throws UnconfiguredNamedOptionException. Is this a bug in Compono.Options?", + "expected_output": "No - this is intentional, documented behavior, not a bug. Compono.Options throws UnconfiguredNamedOptionException for a name never established via TestOptionsSource.Change(name, value), diverging deliberately from real IOptionsFactory (which silently returns new TOptions() for an unmatched name). The fix is to call source.Change(\"secondary\", value) for that name before resolving it.", + "files": [], + "expectations": [ + "States this is intentional/deliberate behavior, not a bug to work around", + "Explains the fix is calling TestOptionsSource.Change(name, value) for that name before resolving it", + "Does not suggest catching/suppressing the exception, or that it indicates a Compono.Options defect", + "Optionally notes this diverges deliberately from real IOptionsFactory's silent-default behavior" + ] } ] } diff --git a/skills/compono/references/options.md b/skills/compono/references/options.md new file mode 100644 index 00000000..383328a0 --- /dev/null +++ b/skills/compono/references/options.md @@ -0,0 +1,134 @@ +# Compono.Options + +Only relevant if the project references `Compono.Options`. Two public +types: `TestOptionsSource` (the source of truth) and +`UnconfiguredNamedOptionException`, plus the `UseOptions()` builder +extension. + +```csharp +var source = new TestOptionsSource( + new SkillServiceConfiguration { ApiKey = "test-key" }); + +var composer = Composer.Create(builder => builder.UseOptions(source)); + +var service = composer.Create(); +``` + +## When to recommend it + +The composed type depends on `IOptions`, `IOptionsSnapshot`, or +`IOptionsMonitor` for a settings type and `Compono.Options` is +referenced. Recommend it over a hand-rolled `IOptionsMonitor` fake — +the community's standard answer (Ben Foster's widely-cited +`TestOptionsMonitor`) has real, documented bugs: `Get(name)` ignoring +`name` entirely, only one `OnChange` subscriber ever honored, a no-op +`IDisposable`. Recommend it over two/three separately hand-wired +registrations for the same settings type (`Register` ++ `Register>(...)` written +independently) — nothing enforces those stay consistent; `UseOptions` +does, structurally, from one source. + +## `IConfiguration` vs. `Compono.Options` — the routing distinction + +These are two different, independently-composable things. Never conflate +them or invent a `Compono.Configuration` package: + +- The composed type depends on plain `IConfiguration` directly + (`GetSection`, `GetValue`, configuration binding) → ordinary + `ConfigurationBuilder`/`AddInMemoryCollection`/`Register` + composition. No dedicated Compono package exists or is needed for this — + point at the Configuration Cookbook recipes + (`docs/cookbook/compose-configuration-from-an-in-memory-collection.md` + and its neighbors) rather than inventing one. +- The composed type depends on `IOptions`/`IOptionsSnapshot`/ + `IOptionsMonitor` for a strongly-typed settings class → + `Compono.Options`. + +The two compose independently and their values need not agree with each +other — a test can configure `IConfiguration` and a `TestOptionsSource` +with completely unrelated values; `Compono.Options` never reads real +`IConfiguration` at all. + +## Core usage vocabulary + +- **`TestOptionsSource`** — construct directly per settings type. The + constructor's `initialValue` establishes the default (`Options.DefaultName`) + value immediately — no separate setup call is needed before resolving + `IOptions`/`CurrentValue`. Implements `IOptionsMonitor` directly. +- **`CompositionBuilder.UseOptions(source)`** — the one wiring call. + Reads identically inline or inside `ICompositionProfile.Configure`. +- **`.Change(value)`** / **`.Change(name, value)`** — the one mutation + surface. Establishes the value (first time or an update) then + synchronously fires `OnChange` subscribers for that name. There is no + separate "add"/"configure" API — never suggest one. +- **`OnChange(Action listener)`** returns a real, + independently-disposable `IDisposable` per subscription. + +## Identity model — what a consumer observes + +- **`IOptions`** — one frozen view, shared for the whole composition + graph. Resolving it twice in the same graph returns the same instance; + it never reflects a later `.Change(...)`. +- **`IOptionsMonitor`** — `source` itself (stable identity, one per + graph). `CurrentValue`/`Get(name)` always reflect `source`'s current + state; `OnChange` subscribes to live updates. +- **`IOptionsSnapshot`** — a **fresh** frozen view captured from + `source`'s *current* state on **every** resolution — not shared. One + resolved snapshot instance stays frozen even if `source` changes + afterward; a *new* resolution after a change sees the new state. Never + claim two `IOptionsSnapshot` resolutions in the same graph return the + same instance — they deliberately don't. + +This mirrors real `Microsoft.Extensions.Options`: `IOptions` and +`IOptionsSnapshot` are the exact same behavior in real Microsoft code +(`OptionsManager` implements both) — the only real difference is how +many instances exist, controlled there by DI registration lifetime +(Singleton vs. Scoped) and here by Compono's own `Share()` vs. plain +`Register()`. + +## Named options + +`IOptionsMonitor.Get(name)` and `IOptionsSnapshot.Get(name)` accept a +name; **`IOptions` has no named-lookup surface at all** — only `Value`. +Never claim or write code implying a consumer holding only `IOptions` +can request a named value. Name comparison is case-sensitive (ordinal). + +## Unconfigured named options — intentional, not a bug + +Unlike real `IOptionsFactory.Create(name)` (which silently returns +`new TOptions()` for an unmatched name), `Compono.Options` throws +`UnconfiguredNamedOptionException` — naming the settings type and the +requested name — for a name never established via `.Change(name, value)`. +This is deliberate, the same explicit-configuration-over-silent-default +tradeoff `Compono.TestDoubles` already ships +(`references/testdoubles.md`'s configuration-required-members behavior). +When a user asks "why did my test throw `UnconfiguredNamedOptionException`," +the answer is: that name was never given a value via `.Change(name, value)` +on the `TestOptionsSource` — fix by calling `.Change(name, value)` for +that name before resolving it, not by treating the exception as a bug. + +## Disposal + +`TestOptionsSource` does **not** implement `IDisposable`/ +`IAsyncDisposable`. Never suggest disposing a `TestOptionsSource` or +adding a `Dispose()`/"clear all subscribers" method — it owns no +disposable resource. The `IDisposable` `OnChange(...)` returns is the only +disposal surface, and it unregisters exactly one subscription. + +## ADR-0052 Finding B — this package doesn't solve it + +There is **no automatic, no-registration composition** for an arbitrary +settings type `T`. A consumer always constructs `TestOptionsSource` +explicitly and calls `UseOptions()` — the value comes from the test, +never from Compono resolving `T` itself. Never suggest "just declare an +`IOptions`/`IOptionsMonitor` constructor dependency with no +registration and Compono will figure it out" — that's exactly the Finding +B gap (`references/composition-model.md`/`references/diagnostics.md`) +this package sidesteps by design, not solves. + +## What this package doesn't do + +No real `IConfiguration`/change-token/file-watcher simulation, no +`IOptionsFactory` pipeline, no DI-scope/container simulation, no +`Compono.Configuration` package, no `CallVerifier`-based verification of +`Change`/`OnChange` call counts. Never invent any of these. diff --git a/src/Compono.Options/Compono.Options.csproj b/src/Compono.Options/Compono.Options.csproj new file mode 100644 index 00000000..65e4e392 --- /dev/null +++ b/src/Compono.Options/Compono.Options.csproj @@ -0,0 +1,43 @@ + + + + latest + enable + enable + net8.0;net9.0;net10.0;net11.0 + Compono — Configuration/Options Testing Support + First-class Microsoft.Extensions.Options testing support for Compono - TestOptionsSource<T> is a reflection-free, hand-written source of truth that coherently backs IOptions<T>/IOptionsSnapshot<T>/IOptionsMonitor<T> for one settings type from one test-configured instance, wired via a single UseOptions<T>() composition call. + + true + + + + + + + + + + + + + + + + + <_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)"> + [%(ProjectVersion)] + + + + + diff --git a/src/Compono.Options/CompositionBuilderExtensions.cs b/src/Compono.Options/CompositionBuilderExtensions.cs new file mode 100644 index 00000000..9ed213bb --- /dev/null +++ b/src/Compono.Options/CompositionBuilderExtensions.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; + +namespace Compono.Options; + +/// +/// extension wiring a into +/// composition - the one call a test or needs to coherently satisfy +/// , , and +/// for a settings type from one source. See +/// docs/adr/0061-compono-options-testing-support.md's "Decision Outcome" for the full identity-model +/// rationale. +/// +public static class CompositionBuilderExtensions +{ + /// + /// Registers so that, within one composition graph: + /// and both resolve to a single shared identity (the source itself + /// for Monitor; one frozen view captured once for ), while + /// resolves to a fresh frozen view - capturing + /// 's then-current state - on every resolution. + /// + /// The settings type is the source of truth for. + /// The builder to configure. + /// The source a test constructed and configured directly. + public static CompositionBuilder UseOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(this CompositionBuilder builder, TestOptionsSource source) + where T : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + builder.Register>(() => source).Share>(); + builder.Register>(() => new FrozenOptionsView(source)).Share>(); + builder.Register>(() => new FrozenOptionsView(source)); + + return builder; + } +} diff --git a/src/Compono.Options/FrozenOptionsView.cs b/src/Compono.Options/FrozenOptionsView.cs new file mode 100644 index 00000000..19a456c7 --- /dev/null +++ b/src/Compono.Options/FrozenOptionsView.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; +using MSOptions = Microsoft.Extensions.Options.Options; + +namespace Compono.Options; + +// Backs both IOptions and IOptionsSnapshot - never public, never named by consumer code +// (docs/adr/0061-compono-options-testing-support.md's "Public object model" section). Captures a +// snapshot copy of TestOptionsSource's state at construction time and never touches the source +// again - the only difference between the two Microsoft interfaces this backs is how often +// UseOptions's wiring constructs a new instance (shared once for IOptions, fresh per +// resolution for IOptionsSnapshot), exactly matching real OptionsManager's own behavior. +internal sealed class FrozenOptionsView<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(TestOptionsSource source) : IOptions, IOptionsSnapshot + where T : class +{ + private readonly IReadOnlyDictionary _values = source.CaptureSnapshot(); + + public T Value => Get(MSOptions.DefaultName); + + public T Get(string? name) + { + var effectiveName = name ?? MSOptions.DefaultName; + if (_values.TryGetValue(effectiveName, out var value)) + { + return value; + } + + throw new UnconfiguredNamedOptionException(typeof(T), effectiveName); + } +} diff --git a/src/Compono.Options/TestOptionsSource.cs b/src/Compono.Options/TestOptionsSource.cs new file mode 100644 index 00000000..7ff51734 --- /dev/null +++ b/src/Compono.Options/TestOptionsSource.cs @@ -0,0 +1,126 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; +using MSOptions = Microsoft.Extensions.Options.Options; + +namespace Compono.Options; + +/// +/// The one hand-written, reflection-free source of truth for a settings type - +/// a test constructs this directly, configures/mutates it, and wires it into composition via +/// . Implements +/// directly, since Monitor's live, subscribable contract is +/// exactly what a mutable source naturally is; and +/// are satisfied by an internal frozen view captured from this +/// source, never by this type itself. See docs/adr/0061-compono-options-testing-support.md's "Public +/// object model" and "Concurrent access to one source instance" sections for the full rationale. +/// +/// +/// Deliberately does not implement / - it owns no +/// disposable resource. A subscription returned by is the only thing a test +/// disposes. See the ADR's "Source disposal" section. +/// +/// The settings type this source is the source of truth for. +public sealed class TestOptionsSource<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T> : IOptionsMonitor + where T : class +{ + private readonly object _gate = new(); + private readonly Dictionary _values = new(StringComparer.Ordinal); + private event Action? OnChanged; + + /// Creates a source whose default-named value () is . + /// The default value, established immediately - no separate setup call is required before resolving . + public TestOptionsSource(T initialValue) + { + ArgumentNullException.ThrowIfNull(initialValue); + _values[MSOptions.DefaultName] = initialValue; + } + + /// + public T CurrentValue => Get(MSOptions.DefaultName); + + /// + /// (or the default name) was never established via /. + public T Get(string? name) + { + var effectiveName = name ?? MSOptions.DefaultName; + lock (_gate) + { + if (_values.TryGetValue(effectiveName, out var value)) + { + return value; + } + } + + throw new UnconfiguredNamedOptionException(typeof(T), effectiveName); + } + + /// + /// Establishes or updates the default-named () value, then + /// synchronously notifies every current subscriber. The new value is visible + /// via / before any subscriber is invoked, matching real + /// OptionsMonitor<T>'s cache-then-invoke ordering. + /// + /// The new default value. + public void Change(T value) => Change(MSOptions.DefaultName, value); + + /// + /// Establishes or updates 's value, then synchronously notifies every current + /// subscriber for that name. This is also how a named value is first + /// established - there is no separate "add" API. The new value is visible via + /// before any subscriber is invoked. Matches real OptionsMonitor<T>: synchronous, no + /// per-subscriber exception isolation (a throwing subscriber blocks subsequent ones). + /// + /// The option name - for the default. + /// The new value for . + public void Change(string name, T value) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(value); + + lock (_gate) + { + _values[name] = value; + } + + OnChanged?.Invoke(value, name); + } + + /// + /// + /// The returned unsubscribes when disposed, and + /// disposing it more than once is a no-op. Multiple independent subscriptions are supported and + /// dispose independently of one another. + /// + public IDisposable OnChange(Action listener) + { + ArgumentNullException.ThrowIfNull(listener); + OnChanged += listener; + return new ChangeSubscription(this, listener); + } + + private void Unsubscribe(Action listener) => OnChanged -= listener; + + // Captures a point-in-time copy of every currently-configured name/value pair - used by + // FrozenOptionsView to build a snapshot that never touches this source again after + // construction (docs/adr/0061 "IOptions"/"IOptionsSnapshot" identity model). + internal IReadOnlyDictionary CaptureSnapshot() + { + lock (_gate) + { + return new Dictionary(_values, StringComparer.Ordinal); + } + } + + private sealed class ChangeSubscription(TestOptionsSource source, Action listener) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + source.Unsubscribe(listener); + } + } + } +} diff --git a/src/Compono.Options/UnconfiguredNamedOptionException.cs b/src/Compono.Options/UnconfiguredNamedOptionException.cs new file mode 100644 index 00000000..7c017637 --- /dev/null +++ b/src/Compono.Options/UnconfiguredNamedOptionException.cs @@ -0,0 +1,27 @@ +namespace Compono.Options; + +/// +/// Thrown when (or the frozen view backing +/// /) +/// is asked for a named value that was never established via +/// . This is an intentional divergence from real +/// IOptionsFactory<T>, which silently returns new TOptions() for an unmatched name - +/// see docs/adr/0061-compono-options-testing-support.md's "Unconfigured named option" decision, the +/// same explicit-configuration-over-silent-default tradeoff Compono.TestDoubles already made +/// under ADR-0045. +/// +public sealed class UnconfiguredNamedOptionException : Exception +{ + /// Creates an exception describing the settings type and the unconfigured name. + /// The settings type (T in ) that was requested. + /// The requested option name - for the default. + public UnconfiguredNamedOptionException(Type optionsType, string name) + : base(BuildMessage(optionsType, name)) + { + } + + private static string BuildMessage(Type optionsType, string name) => + name.Length == 0 + ? $"No default value configured for '{optionsType.Name}'. Call TestOptionsSource<{optionsType.Name}>.Change(value) before resolving it." + : $"No value configured for '{optionsType.Name}' named \"{name}\". Call TestOptionsSource<{optionsType.Name}>.Change(\"{name}\", value) before resolving it."; +} diff --git a/test/Compono.Options.AotSmokeTest/Compono.Options.AotSmokeTest.csproj b/test/Compono.Options.AotSmokeTest/Compono.Options.AotSmokeTest.csproj new file mode 100644 index 00000000..50ff1835 --- /dev/null +++ b/test/Compono.Options.AotSmokeTest/Compono.Options.AotSmokeTest.csproj @@ -0,0 +1,46 @@ + + + + + Exe + + net10.0 + enable + enable + false + false + true + + false + + $(MSBuildThisFileDirectory)obj/.nuget-packages/ + + + + + + + + + + + + + + + diff --git a/test/Compono.Options.AotSmokeTest/Program.cs b/test/Compono.Options.AotSmokeTest/Program.cs new file mode 100644 index 00000000..1e5c7b4d --- /dev/null +++ b/test/Compono.Options.AotSmokeTest/Program.cs @@ -0,0 +1,72 @@ +using Compono; +using Compono.Options; +using Microsoft.Extensions.Options; + +namespace Compono.Options.AotSmokeTest; + +internal sealed record ServiceSettings(string Endpoint, int TimeoutSeconds); + +internal static class Program +{ + private static int Main() + { + try + { + var source = new TestOptionsSource(new ServiceSettings("https://a", 30)); + + var composer = Composer.Create(builder => builder.UseOptions(source)); + var options = composer.Create>(); + var snapshotBeforeChange = composer.Create>(); + var monitor = composer.Create>(); + + if (options.Value != new ServiceSettings("https://a", 30)) + throw new InvalidOperationException($"Unexpected IOptions value: {options.Value}."); + + if (!ReferenceEquals(monitor, source)) + throw new InvalidOperationException("IOptionsMonitor should resolve to the source itself."); + + ServiceSettings? observed = null; + using var subscription = monitor.OnChange((value, _) => observed = value); + + source.Change(new ServiceSettings("https://b", 60)); + + if (observed != new ServiceSettings("https://b", 60)) + throw new InvalidOperationException($"Unexpected OnChange-observed value: {observed}."); + + if (options.Value != new ServiceSettings("https://a", 30)) + throw new InvalidOperationException("IOptions should stay frozen after a later Change()."); + + if (snapshotBeforeChange.Value != new ServiceSettings("https://a", 30)) + throw new InvalidOperationException("The already-resolved IOptionsSnapshot should stay frozen after a later Change()."); + + var snapshotAfterChange = composer.Create>(); + if (snapshotAfterChange.Value != new ServiceSettings("https://b", 60)) + throw new InvalidOperationException("A newly-resolved IOptionsSnapshot should see the source's current state."); + + source.Change("named", new ServiceSettings("https://named", 10)); + if (monitor.Get("named") != new ServiceSettings("https://named", 10)) + throw new InvalidOperationException("Named option value did not round-trip through IOptionsMonitor.Get(name)."); + + try + { + monitor.Get("never-configured"); + throw new InvalidOperationException("Expected UnconfiguredNamedOptionException for an unconfigured name."); + } + catch (UnconfiguredNamedOptionException) + { + // expected + } + + Console.WriteLine( + "PASS: TestOptionsSource/UseOptions - IOptions/IOptionsSnapshot/IOptionsMonitor " + + "identity, change notification, named options, and the unconfigured-name diagnostic all " + + "survived Native AOT through the packaged Compono.Options dependency chain."); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"FAIL: {ex}"); + return 1; + } + } +} diff --git a/test/Compono.Options.AotSmokeTest/nuget.config b/test/Compono.Options.AotSmokeTest/nuget.config new file mode 100644 index 00000000..64575315 --- /dev/null +++ b/test/Compono.Options.AotSmokeTest/nuget.config @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/test/Compono.Options.AotSmokeTest/pack-compono.sh b/test/Compono.Options.AotSmokeTest/pack-compono.sh new file mode 100755 index 00000000..59ca56bb --- /dev/null +++ b/test/Compono.Options.AotSmokeTest/pack-compono.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Packs Compono and Compono.Options into this project's own local NuGet feed (nuget.config) so the +# AOT publish-and-run proof consumes Compono.Options via an ordinary PackageReference - the same way +# any real consumer does - rather than a ProjectReference. Mirrors +# test/Compono.Logging.AotSmokeTest/pack-compono.sh, packing Compono.Options instead of +# Compono.Logging. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +feed_dir="$script_dir/../../.local-nuget-feed-options-aot-smoke" +restore_packages_path="$script_dir/obj/.nuget-packages" + +mkdir -p "$feed_dir" +rm -f "$feed_dir"/Compono.*.nupkg + +# NuGet treats a package id+version already present in a packages folder as immutable and never +# re-extracts it - without clearing this project's own isolated restore path, a rerun after changing +# Compono/Compono.Options source would silently keep serving the first run's cached packages instead +# of the freshly repacked nupkgs below. +rm -rf "$restore_packages_path" + +dotnet pack "$script_dir/../../src/Compono/Compono.csproj" -c Release -o "$feed_dir" -p:Version=1.0.0 --nologo +dotnet pack "$script_dir/../../src/Compono.Options/Compono.Options.csproj" -c Release -o "$feed_dir" -p:Version=1.0.0 --nologo diff --git a/test/Compono.Options.Tests/Compono.Options.Tests.csproj b/test/Compono.Options.Tests/Compono.Options.Tests.csproj new file mode 100644 index 00000000..6da2d626 --- /dev/null +++ b/test/Compono.Options.Tests/Compono.Options.Tests.csproj @@ -0,0 +1,24 @@ + + + + enable + enable + Exe + Compono.Options.Tests + false + true + + true + true + + + + + + + + + + + + diff --git a/test/Compono.Options.Tests/CompositionBuilderExtensionsTests.cs b/test/Compono.Options.Tests/CompositionBuilderExtensionsTests.cs new file mode 100644 index 00000000..d5c3d6a2 --- /dev/null +++ b/test/Compono.Options.Tests/CompositionBuilderExtensionsTests.cs @@ -0,0 +1,267 @@ +using Microsoft.Extensions.Options; + +namespace Compono.Options.Tests; + +/// +/// CompositionBuilderExtensions.UseOptions<T>'s graph-level identity/coherence contract - +/// // +/// all backed by one . See +/// docs/adr/0061-compono-options-testing-support.md's "Decision Outcome" and docs/plans/0064-... Task 5. +/// +public sealed class CompositionBuilderExtensionsTests +{ + private sealed record Settings(string Value); + + // IOptions + + [Fact] + public void IOptions_Value_ReflectsTheSourcesValue_AtTheMomentOfFirstResolution() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + + var options = composer.Create>(); + + options.Value.Should().Be(new Settings("v0")); + } + + [Fact] + public void IOptions_SameInstance_OnEveryResolution_WithinOneGraph() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + var row = composer.CreateRow(typeof(CompositionBuilderExtensionsTests)); + + var first = row.Resolve>(Descriptor(0)); + var second = row.Resolve>(Descriptor(1)); + + ReferenceEquals(first, second).Should().BeTrue(); + } + + [Fact] + public void IOptions_RemainsUnchanged_AfterALaterChangeOnTheSource() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + + var options = composer.Create>(); + source.Change(new Settings("v1")); + + options.Value.Should().Be(new Settings("v0")); + } + + // IOptionsSnapshot + + [Fact] + public void IOptionsSnapshot_ReflectsTheSourcesCurrentState_AtResolutionTime() + { + var source = new TestOptionsSource(new Settings("v0")); + source.Change(new Settings("v1")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + + var snapshot = composer.Create>(); + + snapshot.Value.Should().Be(new Settings("v1")); + } + + [Fact] + public void IOptionsSnapshot_RepeatedReads_OnTheSameResolvedInstance_StayStable_EvenIfTheSourceChangesAfterward() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + + var snapshot = composer.Create>(); + source.Change(new Settings("v1")); + + snapshot.Value.Should().Be(new Settings("v0")); + snapshot.Value.Should().Be(new Settings("v0")); + } + + [Fact] + public void IOptionsSnapshot_ASecondLaterResolution_AfterASourceChange_ProducesANewInstance_ReflectingTheNewState() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + var row = composer.CreateRow(typeof(CompositionBuilderExtensionsTests)); + + var first = row.Resolve>(Descriptor(0)); + source.Change(new Settings("v1")); + var second = row.Resolve>(Descriptor(1)); + + ReferenceEquals(first, second).Should().BeFalse(); + first.Value.Should().Be(new Settings("v0")); + second.Value.Should().Be(new Settings("v1")); + } + + [Fact] + public void IOptionsSnapshot_NamedValues_WorkIdenticallyToMonitors() + { + var source = new TestOptionsSource(new Settings("default")); + source.Change("named", new Settings("named-value")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + + var snapshot = composer.Create>(); + + snapshot.Get("named").Should().Be(new Settings("named-value")); + } + + // IOptionsMonitor + + [Fact] + public void IOptionsMonitor_IsTheSourceItself() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + + var monitor = composer.Create>(); + + ReferenceEquals(monitor, source).Should().BeTrue(); + } + + [Fact] + public void IOptionsMonitor_SameInstance_OnEveryResolution_WithinOneGraph() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + var row = composer.CreateRow(typeof(CompositionBuilderExtensionsTests)); + + var first = row.Resolve>(Descriptor(0)); + var second = row.Resolve>(Descriptor(1)); + + ReferenceEquals(first, second).Should().BeTrue(); + } + + // Unconfigured named options - Monitor and Snapshot lookup surfaces only (IOptions has no + // named-lookup surface at all - see the exception-message test below for its own throw path). + + [Fact] + public void IOptionsMonitor_Get_ForAnUnconfiguredName_Throws() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + var monitor = composer.Create>(); + + var act = () => monitor.Get("missing"); + + act.Should().Throw(); + } + + [Fact] + public void IOptionsSnapshot_Get_ForAnUnconfiguredName_Throws() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + var snapshot = composer.Create>(); + + var act = () => snapshot.Get("missing"); + + act.Should().Throw(); + } + + // Note: IOptions exposes no named-lookup surface at all (only Value), and its one lookup path - + // the default name - is always established by TestOptionsSource's constructor, so + // UnconfiguredNamedOptionException is never observably reachable through IOptions in practice. + // Diagnostic message coverage is therefore Monitor + Snapshot only (below), not all three + // interfaces - see docs/plans/0064-... Notes. + + // Coherence - the central cross-interface claim + + [Fact] + public void Coherence_OneSourceOneWiringCall_KeepsAllThreeInterfacesCorrectlyRelated_AcrossAChange() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + var row = composer.CreateRow(typeof(CompositionBuilderExtensionsTests)); + + var options = row.Resolve>(Descriptor(0)); + var monitor = row.Resolve>(Descriptor(1)); + var firstSnapshot = row.Resolve>(Descriptor(2)); + + source.Change(new Settings("v1")); + + var secondSnapshot = row.Resolve>(Descriptor(3)); + + monitor.CurrentValue.Should().Be(new Settings("v1"), "Monitor sees the new value"); + options.Value.Should().Be(new Settings("v0"), "the already-resolved IOptions stays frozen"); + firstSnapshot.Value.Should().Be(new Settings("v0"), "the already-resolved Snapshot stays frozen"); + secondSnapshot.Value.Should().Be(new Settings("v1"), "a newly-resolved Snapshot after the change sees the new value"); + } + + // Registration/composition + + [Fact] + public void OrdinaryFirstRegistrationWinsPrecedence_HoldsUnchanged_ForAnExplicitConsumerOverride() + { + var source = new TestOptionsSource(new Settings("v0")); + var explicitInstance = Microsoft.Extensions.Options.Options.Create(new Settings("explicit")); + + var act = () => Composer.Create(builder => builder + .Register>(() => explicitInstance) + .UseOptions(source)); + + // UseOptions also calls Register>(...) internally - an explicit consumer + // registration before it collides under Compono's ordinary, unchanged strict + // duplicate-registration rule. No special-cased precedence is introduced by this package. + act.Should().Throw(); + } + + [Fact] + public void ShareIsUsedCorrectly_ForIOptionsAndIOptionsMonitor_AndCorrectlyNotUsedForIOptionsSnapshot() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + var row = composer.CreateRow(typeof(CompositionBuilderExtensionsTests)); + + var optionsA = row.Resolve>(Descriptor(0)); + var optionsB = row.Resolve>(Descriptor(1)); + var monitorA = row.Resolve>(Descriptor(2)); + var monitorB = row.Resolve>(Descriptor(3)); + var snapshotA = row.Resolve>(Descriptor(4)); + var snapshotB = row.Resolve>(Descriptor(5)); + + ReferenceEquals(optionsA, optionsB).Should().BeTrue("IOptions is shared"); + ReferenceEquals(monitorA, monitorB).Should().BeTrue("IOptionsMonitor is shared"); + ReferenceEquals(snapshotA, snapshotB).Should().BeFalse("IOptionsSnapshot is deliberately not shared"); + } + + [Fact] + public void UseOptions_ReadsIdentically_InlineAndInsideAProfile() + { + var inlineSource = new TestOptionsSource(new Settings("v0")); + var inlineComposer = Composer.Create(builder => builder.UseOptions(inlineSource)); + + var profileSource = new TestOptionsSource(new Settings("v0")); + var profileComposer = Composer.Create(builder => builder.AddProfile(new SettingsProfile(profileSource))); + + inlineComposer.Create>().Value.Should().Be(profileComposer.Create>().Value); + } + + // Argument validation + + [Fact] + public void UseOptions_NullBuilder_ThrowsArgumentNullException() + { + CompositionBuilder builder = null!; + var source = new TestOptionsSource(new Settings("v0")); + + var act = () => builder.UseOptions(source); + + act.Should().Throw(); + } + + [Fact] + public void UseOptions_NullSource_ThrowsArgumentNullException() + { + var act = () => Composer.Create(builder => builder.UseOptions(null!)); + + act.Should().Throw(); + } + + private static CompositionRequestDescriptor Descriptor(int ordinal) => + new(CompositionRequestKind.TestParameter, ordinal, $"p{ordinal}", declaringType: typeof(CompositionBuilderExtensionsTests), Nullability.NotNullable); + + private sealed class SettingsProfile(TestOptionsSource source) : ICompositionProfile + { + public void Configure(CompositionBuilder builder) => builder.UseOptions(source); + } +} diff --git a/test/Compono.Options.Tests/TestOptionsSourceTests.cs b/test/Compono.Options.Tests/TestOptionsSourceTests.cs new file mode 100644 index 00000000..57480d62 --- /dev/null +++ b/test/Compono.Options.Tests/TestOptionsSourceTests.cs @@ -0,0 +1,303 @@ +using Microsoft.Extensions.Options; + +namespace Compono.Options.Tests; + +/// +/// 's own behavior, named +/// options, change notification, disposal, and concurrency - independent of composition wiring +/// ( covers the graph-level identity/coherence +/// contract). See docs/adr/0061-compono-options-testing-support.md and docs/plans/0064-... Task 5. +/// +public sealed class TestOptionsSourceTests +{ + private sealed record Settings(string Value); + + // IOptionsMonitor contract + + [Fact] + public void CurrentValue_ReturnsTheInitialValue() + { + var source = new TestOptionsSource(new Settings("initial")); + + source.CurrentValue.Should().Be(new Settings("initial")); + } + + [Fact] + public void CurrentValue_AndGetDefaultName_Agree() + { + var source = new TestOptionsSource(new Settings("initial")); + + source.CurrentValue.Should().Be(source.Get(Microsoft.Extensions.Options.Options.DefaultName)); + } + + [Fact] + public void Get_ForAConfiguredName_ReturnsThatNamesValue() + { + var source = new TestOptionsSource(new Settings("default")); + source.Change("named", new Settings("named-value")); + + source.Get("named").Should().Be(new Settings("named-value")); + } + + [Fact] + public void Change_UpdatesCurrentValue() + { + var source = new TestOptionsSource(new Settings("initial")); + + source.Change(new Settings("updated")); + + source.CurrentValue.Should().Be(new Settings("updated")); + } + + [Fact] + public void Change_FiresSubscribers() + { + var source = new TestOptionsSource(new Settings("initial")); + Settings? observed = null; + string? observedName = null; + source.OnChange((value, name) => + { + observed = value; + observedName = name; + }); + + source.Change(new Settings("updated")); + + observed.Should().Be(new Settings("updated")); + observedName.Should().Be(Microsoft.Extensions.Options.Options.DefaultName); + } + + [Fact] + public void Change_ValueIsVisibleFromInsideTheChangeCallback() + { + var source = new TestOptionsSource(new Settings("initial")); + Settings? observedCurrentValue = null; + source.OnChange((_, _) => observedCurrentValue = source.CurrentValue); + + source.Change(new Settings("updated")); + + observedCurrentValue.Should().Be(new Settings("updated")); + } + + [Fact] + public void Change_CallbacksAreSynchronous() + { + var source = new TestOptionsSource(new Settings("initial")); + var invokedOnCallingThread = false; + var callingThreadId = Environment.CurrentManagedThreadId; + source.OnChange((_, _) => invokedOnCallingThread = Environment.CurrentManagedThreadId == callingThreadId); + + source.Change(new Settings("updated")); + + invokedOnCallingThread.Should().BeTrue(); + } + + [Fact] + public void Change_TwoIndependentSubscribers_BothFire() + { + var source = new TestOptionsSource(new Settings("initial")); + var firstFired = false; + var secondFired = false; + source.OnChange((_, _) => firstFired = true); + source.OnChange((_, _) => secondFired = true); + + source.Change(new Settings("updated")); + + firstFired.Should().BeTrue(); + secondFired.Should().BeTrue(); + } + + [Fact] + public void DisposingOneSubscription_StopsOnlyThatListener() + { + var source = new TestOptionsSource(new Settings("initial")); + var firstFireCount = 0; + var secondFireCount = 0; + var firstSubscription = source.OnChange((_, _) => firstFireCount++); + source.OnChange((_, _) => secondFireCount++); + + firstSubscription.Dispose(); + source.Change(new Settings("updated")); + + firstFireCount.Should().Be(0); + secondFireCount.Should().Be(1); + } + + [Fact] + public void DisposingASubscriptionTwice_DoesNotThrow_AndDoesNotAffectOtherSubscriptions() + { + var source = new TestOptionsSource(new Settings("initial")); + var otherFireCount = 0; + var subscription = source.OnChange((_, _) => { }); + source.OnChange((_, _) => otherFireCount++); + + subscription.Dispose(); + var act = subscription.Dispose; + + act.Should().NotThrow(); + source.Change(new Settings("updated")); + otherFireCount.Should().Be(1); + } + + [Fact] + public void AThrowingSubscriber_PreventsALaterRegisteredSubscriberInTheSameInvocation_FromFiring() + { + // Matches real OptionsMonitor exactly - no per-subscriber exception isolation. Asserted here + // as intended, documented behavior, not an accidental discovery - docs/adr/0061's + // "Change-notification robustness" section. + var source = new TestOptionsSource(new Settings("initial")); + var laterSubscriberFired = false; + source.OnChange((_, _) => throw new InvalidOperationException("boom")); + source.OnChange((_, _) => laterSubscriberFired = true); + + var act = () => source.Change(new Settings("updated")); + + act.Should().Throw().WithMessage("boom"); + laterSubscriberFired.Should().BeFalse(); + } + + // Named options + + [Fact] + public void DefaultName_AndAnExplicitName_AreIndependent() + { + var source = new TestOptionsSource(new Settings("default")); + source.Change("named", new Settings("named-value")); + + source.CurrentValue.Should().Be(new Settings("default")); + source.Get("named").Should().Be(new Settings("named-value")); + } + + [Fact] + public void NameComparison_IsCaseSensitive() + { + var source = new TestOptionsSource(new Settings("default")); + source.Change("Foo", new Settings("upper")); + + var act = () => source.Get("foo"); + + act.Should().Throw(); + source.Get("Foo").Should().Be(new Settings("upper")); + } + + [Fact] + public void ChangingOneName_DoesNotAffectAnotherName() + { + var source = new TestOptionsSource(new Settings("default")); + source.Change("a", new Settings("a-value")); + source.Change("b", new Settings("b-value")); + + source.Change("a", new Settings("a-updated")); + + source.Get("a").Should().Be(new Settings("a-updated")); + source.Get("b").Should().Be(new Settings("b-value")); + } + + // Diagnostics + + [Fact] + public void Get_ForAnUnconfiguredName_ThrowsUnconfiguredNamedOptionException() + { + var source = new TestOptionsSource(new Settings("default")); + + var act = () => source.Get("missing"); + + act.Should().Throw() + .WithMessage($"*{nameof(Settings)}*missing*"); + } + + [Fact] + public void Get_ForTheDefaultName_WhenOnlyANamedValueWasChanged_StillThrows() + { + var source = new TestOptionsSource(new Settings("default")); + + var act = () => source.Get("configured-elsewhere-but-not-this-name"); + + act.Should().Throw(); + } + + // Disposal (source itself) + + [Fact] + public void TestOptionsSource_DoesNotImplementIDisposableOrIAsyncDisposable() + { + typeof(TestOptionsSource).Should().NotBeAssignableTo(); + typeof(TestOptionsSource).Should().NotBeAssignableTo(); + } + + // Concurrency (focused correctness, not stress/perf benchmarking) + + [Fact] + public async Task ConcurrentReads_WhileAChangeIsInFlight_NeverObserveATornValue() + { + var source = new TestOptionsSource(new Settings("v0")); + var knownValues = Enumerable.Range(0, 50).Select(i => new Settings($"v{i}")).ToArray(); + var observedUnknown = false; + using var barrier = new Barrier(2); + var cancellationToken = TestContext.Current.CancellationToken; + + var writer = Task.Run( + () => + { + barrier.SignalAndWait(cancellationToken); + foreach (var value in knownValues) + { + source.Change(value); + } + }, cancellationToken); + var reader = Task.Run( + () => + { + barrier.SignalAndWait(cancellationToken); + for (var i = 0; i < 5000; i++) + { + var observed = source.CurrentValue; + if (observed.Value != "v0" && !knownValues.Any(v => v == observed)) + { + observedUnknown = true; + } + } + }, cancellationToken); + + await Task.WhenAll(writer, reader); + + observedUnknown.Should().BeFalse(); + } + + [Fact] + public void ConcurrentChanges_ForDifferentNames_DoNotCorruptEachOthersStorage() + { + var source = new TestOptionsSource(new Settings("default")); + const int nameCount = 20; + var names = Enumerable.Range(0, nameCount).Select(i => $"name{i}").ToArray(); + + Parallel.ForEach(names, name => + { + for (var i = 0; i < 100; i++) + { + source.Change(name, new Settings($"{name}-{i}")); + } + }); + + foreach (var name in names) + { + source.Get(name).Value.Should().StartWith(name); + } + } + + [Fact] + public void ConcurrentSubscribeAndUnsubscribe_IncludingDuringAnInFlightNotification_DoesNotThrowOrCorruptTheSubscriberList() + { + var source = new TestOptionsSource(new Settings("v0")); + var iterations = Enumerable.Range(0, 200); + + var act = () => Parallel.ForEach(iterations, _ => + { + var subscription = source.OnChange((_, _) => { }); + source.Change(new Settings(Guid.NewGuid().ToString())); + subscription.Dispose(); + }); + + act.Should().NotThrow(); + } +} diff --git a/test/Compono.Options.Tests/UnconfiguredNamedOptionExceptionTests.cs b/test/Compono.Options.Tests/UnconfiguredNamedOptionExceptionTests.cs new file mode 100644 index 00000000..22b244fb --- /dev/null +++ b/test/Compono.Options.Tests/UnconfiguredNamedOptionExceptionTests.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Options; + +namespace Compono.Options.Tests; + +/// +/// 's message content - asserted per throwing interface +/// surface (, ) per +/// docs/plans/0064-... Task 5 "Diagnostics". is excluded - see the +/// note in for why it can never observably throw this. +/// +public sealed class UnconfiguredNamedOptionExceptionTests +{ + private sealed record Settings(string Value); + + [Fact] + public void Monitor_Message_NamesTheSettingsTypeAndTheRequestedName() + { + var source = new TestOptionsSource(new Settings("v0")); + IOptionsMonitor monitor = source; + + var act = () => monitor.Get("missing-name"); + + act.Should().Throw() + .WithMessage("*Settings*missing-name*"); + } + + [Fact] + public void Snapshot_Message_NamesTheSettingsTypeAndTheRequestedName() + { + var source = new TestOptionsSource(new Settings("v0")); + var composer = Composer.Create(builder => builder.UseOptions(source)); + var snapshot = composer.Create>(); + + var act = () => snapshot.Get("missing-name"); + + act.Should().Throw() + .WithMessage("*Settings*missing-name*"); + } + + [Fact] + public void ExceptionType_IsADedicatedType_NotAGenericFrameworkException() + { + // Matches Compono.Http's UnmatchedHttpRequestException precedent - a dedicated, package-owned + // exception type, not KeyNotFoundException/InvalidOperationException. + typeof(UnconfiguredNamedOptionException).Should().BeDerivedFrom(); + typeof(UnconfiguredNamedOptionException).Should().NotBe(typeof(KeyNotFoundException)); + } +} diff --git a/test/Compono.Options.Tests/xunit.runner.json b/test/Compono.Options.Tests/xunit.runner.json new file mode 100644 index 00000000..86c7ea05 --- /dev/null +++ b/test/Compono.Options.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +}