Skip to content

feat(testdoubles): sequential responses and overload-safe argument matching - #115

Merged
ncipollina merged 14 commits into
mainfrom
fix/compono-skill-testdoubles-matching
Aug 28, 2026
Merged

feat(testdoubles): sequential responses and overload-safe argument matching#115
ncipollina merged 14 commits into
mainfrom
fix/compono-skill-testdoubles-matching

Conversation

@ncipollina

Copy link
Copy Markdown
Contributor

Summary

Validation

  • Full solution sweep: 820/820 passing on net10.0 (Compono.Generators.Tests 274/274 on net10.0 and net11.0).
  • Two real Native AOT publish-and-run passes (Compono.TestDoubles.AotSmokeTest) covering both phases' scenarios.
  • Real dogfood pass against dynamodb-distributed-lock via scripts/dogfood-validate.sh with fresh local packages — 180/180 passing (45/45 × net8.0/9.0/10.0/11.0), zero NSubstitute in the dependency graph after converting its 4 remaining NSubstitute-backed test sites to the new APIs.
  • package-validation's inspect-packed-nupkgs.sh re-run locally against all 8 publishable packages — all green.

Test plan

  • dotnet test full solution sweep (net10.0)
  • Compono.Generators.Tests on net10.0 and net11.0
  • Native AOT publish-and-run (Compono.TestDoubles.AotSmokeTest)
  • dynamodb-distributed-lock dogfood pass via scripts/dogfood-validate.sh
  • inspect-packed-nupkgs.sh re-run locally after the TUnit.Core fix

ncipollina and others added 10 commits August 27, 2026 08:09
Match and UseGeneratedTestDoubles live in the Compono namespace, not
Compono.TestDoubles (the package name).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Snapshot skills/compono/references/testdoubles.md at 644a5ad (pre-fix)
and rerun evals 28-31 against it, per skill-creator's paired-run
requirement for skill-improvement benchmarks. old_skill passes 1/4
(eval 29 only) vs with_skill's 4/4, confirming the fix corrects real
regressions rather than restating already-passing behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codex flagged that with_skill and old_skill runs weren't launched
together, so they don't satisfy skill-creator's paired-run
requirement. Rerun all 8 combinations (evals 28-31 x with_skill/
old_skill) in a single batch against the current skill and the
644a5ad pre-fix snapshot.

Also verified and refuted the accompanying claim that only
testdoubles.md was snapshotted: the snapshot always captured the full
skills/compono directory, including the pre-fix SKILL.md (5139507
also added a guardrail section there).

Net effect: old_skill now fails all 4 evals (0/4, down from 1/4) since
eval 29's prior old_skill pass was a run-to-run fluke, not a stable
result - stronger, cleaner evidence the fix corrects real regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior wording said the earlier baseline "snapshotted only
testdoubles.md" then immediately said it "captured the full
directory" - contradictory. Clarify that only the benchmark's
description of the earlier baseline was wrong, not the snapshot
itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tching

Implements ADR-0054 (sequential/call-count-based responses via
ReturnConfigBuilder<T>.ReturnsSequence(...)/SequenceOutcome.Throw(...))
and ADR-0044 Amendment 21 (overload-safe argument matching via a new
<Member>Matching(Match<T1>, ...) member name sharing ADR-0050's
entries/call-log/lock state with the unchanged discriminator surface),
per PLAN-0054.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	skills/compono/references/testdoubles.md
#114 (dotnet-minor-patch Dependabot group) bumped Directory.Packages.props'
TUnit.Core/TUnit floor from 1.64.13 to 1.65.38, but package-validation.yaml's
inspect-packed-nupkgs.sh still asserted the old floor as Compono.TUnit's
"intended tested range" (ADR-0031 Amendment 1) - a deliberate, human-gated
check that a dependency floor bump has actually been validated, not meant
to silently track whatever's currently pinned. The Dependabot PR's own CI
run already proved 1.65.38 builds and tests clean; this just acknowledges
that in the check itself. Confirmed locally: packed and ran
inspect-packed-nupkgs.sh against all 8 publishable packages, all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both were Accepted (capability) / Proposed (API) pending implementation.
PLAN-0054 has now implemented and validated both (unit/generator-execution/
snapshot coverage, real Native AOT publish-and-run, and a real consumer
dogfood pass) - flip to Accepted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the type: feat New feature label Aug 27, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ea32d0c6f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs Outdated
Comment thread src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs Outdated
Comment thread src/Compono/ReturnConfigBuilder.cs Outdated
Comment thread src/Compono/ReturnConfigBuilder.cs Outdated
- docs/reference/api: regenerate from XML docs (ADR-0032 drift check) -
  SequenceOutcome<T>/SequenceOutcome/ThrownOutcome, ReturnConfig<T>'s new
  HasConfiguredSequence/NextSequenceOutcome(), and
  ReturnConfigBuilder<T>.ReturnsSequence(...) were new public API this
  session added without ever regenerating this directory - would have
  failed docs.yml's drift check on push.
- docs/packages/compono-testdoubles.md: was still stating "no sequential/
  call-count-based responses" and "no argument matching on an overloaded
  member" in four places (Call verification, Multiple response
  configurations per member, and the closing "What it deliberately
  doesn't do" summary) - added "Overload-safe argument matching" and
  "Sequential/call-count-based responses" sections mirroring the skill's
  own testdoubles.md, and corrected every stale negative claim.

Verified: `uv run mkdocs build --clean --strict` succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ncipollina

Copy link
Copy Markdown
Contributor Author

@codex review take a look at the commit 82bccd1 as I haven't had a chance to respond to your other feedback yet.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82bccd15cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/packages/compono-testdoubles.md
Five confirmed issues across two review passes:

- TestDoubleAnalyzer.cs: the <Member>Matching alias-collision check compared
  nullable-aware display-string text instead of real C# signature identity,
  missing a collision between e.g. "string" and "string?" (real signature
  identity never considers nullable annotations). It also only considered
  non-overloaded real members as possible collision sources, missing that
  an ordinary overloaded real member's own discriminator extension can
  collide too. Rewrote both checks to compare real ITypeSymbols via
  SymbolEqualityComparer.Default (nullability-insensitive) across every
  real member sharing the candidate name, not just one eligibility shape.

- ReturnConfigBuilder.cs: ReturnsSequence(...)'s XML doc still described the
  rejected dual-implicit-conversion design (a bare Exception implicitly
  converting) instead of the shipped SequenceOutcome.Throw(...) API.
  Also stored the caller's own params array by reference instead of
  snapshotting it - a caller mutating the array after configuring would
  silently change an already-configured response and violate
  NextSequenceOutcome()'s lock-free-safety premise that the sequence is
  immutable once configured.

- docs/packages/compono-testdoubles.md: the "Why this doesn't apply to an
  overloaded member" paragraph flatly contradicted the newly-added
  "Overload-safe argument matching" section - clarified that the compiler
  spike's finding is scoped to the same-call-site shape, not a blanket
  "no matching on overloaded members" claim.

Also, per this round of review: added a coding-standards.md/AGENTS.md rule
(mirrored, since AGENTS.md is a condensed pointer at coding-standards.md)
capturing the general lesson - a generator's collision-prediction logic
must compare real ITypeSymbols via SymbolEqualityComparer.Default, and
must consider every real member sharing a candidate name, not just the
ones reachable through the code path being written.

Regenerated docs/reference/api for the XML doc change (ADR-0032 drift
check). New regression coverage: two generator-execution snapshot tests
proving both collision-detection fixes fire the hash-suffixed fallback,
one unit test proving the array-snapshot fix.

Validated: full solution sweep (823/823 on net10.0), Compono.Generators.Tests
on net10.0 and net11.0 (276/276 each, zero unexpected snapshot diffs -
every prior fixture's generated output is unchanged), `uv run mkdocs build
--clean --strict` succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ncipollina

Copy link
Copy Markdown
Contributor Author

@codex review focus specifically on commit 7a7cd17, which fixes the 5 issues from your two previous review rounds. Please verify:

  1. src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs — the rewritten <Member>Matching alias-collision check. It now compares real ITypeSymbols via SymbolEqualityComparer.Default instead of nullable-aware display-string text, and considers every real member sharing the candidate name (not just non-overloaded matching-eligible ones) by building each candidate's actual generated-extension parameter-type list (Match<T>-constructed when that candidate is itself matching-eligible, the real declared type otherwise). Please check this is actually correct and complete — is there any other real-member shape (generic, closed-instantiation-eligible, a diamond-inherited one, etc.) that could still collide with a <Member>Matching alias without being caught by this comparison?

  2. src/Compono/ReturnConfigBuilder.csReturnsSequence(...) now clones the params array ((SequenceOutcome<T>[])outcomes.Clone()) before storing it, to fix the caller-array-mutation hazard you flagged. Please verify the clone is placed correctly relative to the rest of the method (no window where a stale/uncloned reference could still leak) and that this doesn't introduce any new issue.

  3. The two new regression tests in test/Compono.Generators.Tests/TestDoubleVerifyTests.cs (OverloadMatchingAliasCollidesDespiteNullableAnnotationMismatch_FallsBackToHashSuffixedName, OverloadMatchingAliasCollidesWithOverloadedRealFamily_FallsBackToHashSuffixedName) — do they actually exercise the specific scenarios from your original findings, or is there a gap in what they prove?

No need to re-review anything before 7a7cd17 unless this commit's changes interact with it in a way that matters.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a7cd17daf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs Outdated
Comment thread src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs Outdated
Two confirmed issues, both in the same collision-detection code the
previous round rewrote:

- The real-signature pre-pass unconditionally excluded every
  closed-instantiation-eligible (ADR-0049) candidate, on the assumption
  only "ordinary" real members needed considering. A non-overloaded
  closed-instantiation-eligible member with real matched parameters emits
  a Match<T>-wrapped generic Configure<T>() extension exactly like the new
  alias does, so it can collide with an overloaded generic member's own
  alias (e.g. Foo<T>(int)/Foo(string) -> FooMatching<T>(Match<int>)) just
  as easily as any other real member. Removed the exclusion - the existing
  isMatchingEligible branch already computes the correct signature for
  both the non-overloaded (Match<T>-wrapped) and overloaded (real,
  unwrapped) closed-instantiation shapes once given the chance to run.

- The stored/compared signature only tracked parameter types, not generic
  arity - also part of real C# signature identity (PLAN-0054's own
  "Naming/collision policy" Finding 4 already established this). A real
  non-generic FooMatching(int) and a generic alias FooMatching<T>(Match<int>)
  share parameter types but not arity, so they don't actually collide -
  the prior comparison treated them as if they did, forcing the whole
  alias family to an unnecessary hash-suffixed name. Signatures are now
  compared as (arity, parameter types) tuples.

New regression coverage: one generator-execution snapshot test proving the
closed-instantiation collision now triggers the fallback, one proving the
arity-only mismatch no longer does (natural name kept, real overload set
still coexists cleanly).

Validated: full solution sweep (825/825 on net10.0), Compono.Generators.Tests
on net10.0 and net11.0 (278/278 each, zero unexpected snapshot diffs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ncipollina

Copy link
Copy Markdown
Contributor Author

@codex review focus specifically on commit 2753543, which fixes the two round-3 findings (closed-instantiation-eligible members excluded from collision detection, and generic arity missing from the signature comparison). Please verify:

  1. src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs — the collision pre-pass no longer excludes closed-instantiation-eligible candidates, and the stored/compared signature is now (Arity, ParameterTypes). Is there any remaining real-member shape (a diamond-resolved member, a DIM-forwarding member, a member only reachable through a different eligibility branch) that still isn't considered a possible collision source? Is the arity computed correctly in every branch (real member side: candidateMethod.TypeParameters.Length; alias side: the original overloaded member's own TypeParameters.Length) — any case where those two arities could be computed inconsistently with what the template actually emits?

  2. The two new regression tests (OverloadMatchingAliasCollidesWithClosedInstantiationEligibleRealMember_FallsBackToHashSuffixedName, OverloadMatchingAliasArityDiffersFromRealMemberOfSameName_KeepsNaturalName) — do they fully exercise the scenarios from your round-3 findings, or is there a gap?

No need to re-review anything before 2753543 unless this commit's changes interact with it in a way that matters.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 27535434d4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs Outdated
A candidate's own TypeParameters.Length is not always its generated
extension's actual emitted arity - TestDoubleMemberInfo.ExtensionIsGeneric
(the template's own governing rule) says a solo (non-overloaded,
non-closed-instantiation) generic member's extension is always emitted
non-generic, mirroring ADR-0044 Requirement 2's "one backing slot covers
every closed instantiation" rule onto a matching-eligible member's own
extension too. The round-3 fix used raw TypeParameters.Length as arity
unconditionally, so a solo generic real member sharing a candidate name
(e.g. FooMatching<T>(int), whose own extension actually emits as the
non-generic FooMatching(Match<int>)) was recorded at the wrong arity and
a genuine collision went undetected.

Computed effective arity now mirrors ExtensionIsGeneric exactly: generic
arity only counts when the candidate is itself overloaded or
closed-instantiation-eligible, zero otherwise - regardless of the
method's own real TypeParameters.Length.

New regression coverage: a generator-execution snapshot test
(OverloadMatchingAliasCollidesWithSoloGenericRealMemberEmittingNonGenericExtension_FallsBackToHashSuffixedName)
reproducing Codex's exact scenario - Foo(int)/Foo<T>(string) plus a solo
real generic FooMatching<T>(int) - confirming the fallback now fires.

Validated: full solution sweep (826/826 on net10.0), Compono.Generators.Tests
on net10.0 and net11.0 (279/279 each, zero unexpected snapshot diffs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ncipollina

Copy link
Copy Markdown
Contributor Author

@codex review focus specifically on commit 71d9fc8, which fixes the round-4 finding (a solo generic real member's effective emitted arity being computed incorrectly - it was using raw TypeParameters.Length instead of mirroring TestDoubleMemberInfo.ExtensionIsGeneric's rule that a solo generic member's extension is always emitted non-generic).

Please verify:

  1. src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs — is the new effectiveArity computation (candidateMethod.IsGenericMethod && (overloadedNames.Contains(...) || IsClosedInstantiationEligibleCandidate(...)) ? candidateMethod.TypeParameters.Length : 0) a complete and correct mirror of ExtensionIsGeneric's own condition in every case reachable through this collision check? Is there a remaining case where the alias side's own arity (overload.TypeParameters.Length, used unconditionally since an overloaded member always satisfies ExtensionIsGeneric when generic) could still be computed inconsistently with what the template actually emits?

  2. Does the new regression test (OverloadMatchingAliasCollidesWithSoloGenericRealMemberEmittingNonGenericExtension_FallsBackToHashSuffixedName) fully exercise the scenario from your finding?

At this point we've been through 4 rounds of review on this same collision-detection logic (<Member>Matching alias naming, ADR-0044 Amendment 21) — if this pass comes back clean, or if anything found is genuinely narrow/theoretical rather than a real reachable CS0111, that's a good signal we're near the point of diminishing returns on this area. No need to re-review anything before 71d9fc8.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 71d9fc83b6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ncipollina
ncipollina merged commit 6f58dee into main Aug 28, 2026
8 checks passed
@ncipollina
ncipollina deleted the fix/compono-skill-testdoubles-matching branch August 28, 2026 01:02
ncipollina added a commit to LayeredCraft/dynamodb-distributed-lock that referenced this pull request Aug 28, 2026
#76)

* feat: migrate to Compono (from AutoFixture) and Compono.TestDoubles (from NSubstitute)

Adopts Compono for composition (replacing AutoFixture) and
Compono.TestDoubles for IAmazonDynamoDB test doubles (replacing
NSubstitute), using the new overload-safe argument matching
(DeleteItemAsyncMatching, ADR-0044 Amendment 21) and sequential/
call-count-based responses (ReturnsSequence, ADR-0054) capabilities -
the two gaps that previously required NSubstitute for
AcquireLockHandleAsync_DisposeHandle_ShouldCallReleaseLock and the three
retry-loop tests. NSubstitute is now fully removed from the dependency
graph.

Validated via scripts/dogfood-validate.sh against local Compono packages
across multiple rounds of PR review on the Compono side (LayeredCraft/compono#115)
before this repo's own Directory.Packages.props is bumped to the real
published release in the next commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: bump Compono/Compono.XunitV3/Compono.TestDoubles to 0.9.0-preview.88

The real published release containing overload-safe argument matching
(ADR-0044 Amendment 21) and sequential/call-count-based responses
(ADR-0054), the two capabilities the previous commit's migration depends
on. Verified against the real package (not a local/dogfood build):
dotnet restore + full dotnet test across all 4 TFMs (net8.0/net9.0/
net10.0/net11.0) - 180/180 passing, matching what scripts/dogfood-validate.sh
already predicted in LayeredCraft/compono#115.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: widen timer-test delay/threshold margin to fix CI flake

Codex review (PR #76): Compono.TestDoubles has no invocation-aware
callback, so DelayedPutItemResponseAsync()/DelayedDeleteItemResponseAsync()
are eagerly invoked (and their own Task.Delay starts counting) at
Configure() time, one statement before the SUT actually awaits them - not
when the SUT invokes the double. Any scheduling/composition overhead
between that Configure() call and the SUT's own internal stopwatch
starting eats directly into the delay budget, which the prior ~5ms delay
against a ">4" threshold had essentially no margin to absorb - the exact
CI failure just observed (2.21ms measured, not a lock-acquisition
correctness bug).

Compono.NSubstitute's invocation-aware Returns(callInfo => ...) would
eliminate the race entirely, but reintroducing it here would partially
undo the very NSubstitute-removal this PR is about. Widened the
delay/threshold margin instead (100ms delay, >20ms threshold) so realistic
CI scheduling overhead can't push the measured duration below threshold.
Verified with 3 consecutive full local test runs (180/180 each).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: use TestContext.Current.CancellationToken for the outer SUT call

DynamoDbDistributedLockTests.cs was passing CancellationToken.None as the
token driving each sut.AcquireLockAsync/ReleaseLockAsync/AcquireLockHandleAsync
call - inconsistent with Retry/RetryIntegrationTests.cs and
Retry/ExponentialBackoffRetryPolicyTests.cs, which already correctly wire
TestContext.Current.CancellationToken so the test framework's own
cancellation (test timeout, --fail-fast, etc.) can actually reach the
async call under test. CancellationToken.None never observes that.

The discriminator-only Configure().PutItemAsync(new PutItemRequest(),
CancellationToken.None)/DeleteItemAsync(...) calls are unaffected and
correctly unchanged - real arguments there are discarded, used only for
compile-time C# overload resolution on a non-matching-eligible overloaded
member, not compared against what the SUT actually passes at runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant