Skip to content

feat(testdoubles): add CallVerifier.AtLeast/AtMost, ReceivedCalls() and ClearCalls() - #134

Merged
ncipollina merged 3 commits into
mainfrom
feat/testdoubles-atleast-atmost-received-calls-clear-calls
Sep 7, 2026
Merged

feat(testdoubles): add CallVerifier.AtLeast/AtMost, ReceivedCalls() and ClearCalls()#134
ncipollina merged 3 commits into
mainfrom
feat/testdoubles-atleast-atmost-received-calls-clear-calls

Conversation

@ncipollina

Copy link
Copy Markdown
Contributor

📋 Summary

Implements PLAN-0063: CallVerifier gains AtLeast(int)/AtMost(int) (ADR-0044 Amendment 22), and Compono.TestDoubles gains generated ReceivedCalls() (retrospective, snapshot-based call inspection for the ADR-0048-eligible member set) and ClearCalls() (whole-double observation reset preserving configured behavior) (ADR-0060).

📝 Changes

  • Core (Compono): CallVerifier.AtLeast/AtMost, matching Exactly's existing structure and exception message format.
  • Compono.Logging: LogVerificationBuilder.AtLeast/AtMost forwarders through the existing ToCallVerifier().
  • Compono.TestDoubles generator/runtime: new generated readonly record struct per ADR-0048-eligible member, ReceivedCalls() bridge (snapshot-based, IReadOnlyList<T>), and ClearCalls() (clears call counts/histories, preserves configured Returns/Throws/sequences). Two real compile errors found and fixed against generator fixtures during implementation (matched-parameters closed-instantiation shape's missing top-level Config field; a type parameter literally named Config shadowing the field).
  • Compono.Http/Compono.TestDoubles: AtLeast/AtMost reachable with zero package-side code changes.
  • Tests: boundary/message tests for CallVerifier, package reachability tests (Compono.Http.Tests, Compono.Logging.Tests), ReceivedCalls()/ClearCalls() coverage (snapshot isolation, reference-retention semantics, sequence non-rewind, concurrency race test), generated-source snapshot tests, and an AOT smoke test extension.
  • Docs: docs/packages/compono-testdoubles.md, compono-http.md, compono-logging.md updated with new sections/bullets.
  • Skill/evals: skills/compono/SKILL.md and references updated to remove stale "not supported" claims; evals.json grew from 46 → 52 evals, with a recorded baseline-vs-updated comparison (Task 12 of the plan) showing no regressions.

🧪 Validation

  • Build/test status: dotnet build -c Release — 0 errors. dotnet test -c Release3567/3567 passed, 0 failed (all target frameworks: net8.0/net9.0/net10.0/net11.0).
  • Manual verification performed: scripts/dogfood-validate.sh run against the trivia-platform consumer repo with a freshly packed local version — 783/783 consumer tests passed, no packaging regression.
  • Edge cases checked: ClearCalls() concurrency race (200-iteration Barrier-synchronized test), reference-retention vs. value-copy semantics for captured arguments, ClearCalls() not rewinding a configured ReturnsSequence, negative/boundary counts for AtLeast/AtMost.

💬 Notes for Reviewers

Full task-by-task execution detail (including the two real compile-error fixes found via generator fixtures, and the mandatory skill eval baseline comparison) is recorded in PLAN-0063's Tasks/Notes sections.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NiVv392P3m46azTD1TpU3s

…nd ClearCalls()

Adds AtLeast(int)/AtMost(int) count verification to core CallVerifier,
forwarded through Compono.Logging's LogVerificationBuilder, and reachable
from Compono.TestDoubles/Compono.Http with no package-side changes
(ADR-0044 Amendment 22). Adds Compono.TestDoubles generated ReceivedCalls()
(snapshot-based retrospective call inspection for ADR-0048-eligible
members) and whole-double ClearCalls() (observation reset preserving
configured behavior) per ADR-0060.

Includes generator/runtime changes, tests across all affected packages
(unit, generated-source snapshots, AOT smoke, concurrency), docs, skill
and eval updates, and a completed baseline-vs-updated eval comparison and
dogfood validation run, all tracked in PLAN-0063.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiVv392P3m46azTD1TpU3s
@github-actions github-actions Bot added the type: feat New feature label Sep 7, 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: 593f54e4df

ℹ️ 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".

// each member's own existing synchronization primitive is used independently, per member.
internal static class {{ safe_identifier }}_ClearCallsExtension
{
public static void ClearCalls(this {{ interface_fully_qualified_name }} self)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reserve the new bridge names before emitting them

When a target interface already declares an applicable zero-argument ClearCalls or ReceivedCalls member, C# instance-member lookup wins over these extensions. For example, repository.ClearCalls() will invoke the interface member implemented by the generated double and silently leave its call history intact. The analyzer's bridge-collision check still reserves only Configure and Verify, so extend that check to reject these collisions with a diagnostic before emitting the double.

AGENTS.md reference: AGENTS.md:L183-L185

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 48816c8: widened TestDoubleAnalyzer's CMP0023 reserved-name collision check to also cover ClearCalls/ReceivedCalls, not just Configure/Verify. Added ClearCallsNamedMember_ReportsCollisionDiagnostic/ReceivedCallsNamedMember_ReportsCollisionDiagnostic generator-fixture regression tests.

Comment on lines +1756 to +1757
if (member.IsEligibleForMatching)
reservedNames.Add(member.ReceivedCallClassName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include received-record names in derived collision analysis

For a valid interface containing an eligible Foo(int value) plus a configurable sibling named Foo_ReceivedCall, the generated double declares both the record type __Foo_ReceivedCall and the sibling's backing field with that same identifier, producing a consumer compilation error. Adding the record name to this callback-only reservation set can rename colliding callback declarations, but it neither renames the record nor feeds the earlier derivedAuxiliaryNameOwners pass that handles sibling-generated declarations; include this new derived name in that collision analysis or otherwise disambiguate it.

AGENTS.md reference: AGENTS.md:L183-L185

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 48816c8: the derived {FieldName}_ReceivedCall record name now feeds into the existing derivedAuxiliaryNameOwners pre-pass alongside _calls/_lock/_Entry/_entries, so a real sibling-name collision demotes the eligible member out of matching eligibility (falls back to its plain configuration surface) instead of emitting a duplicate declaration. Added ReceivedCallRecordNameCollidesWithSiblingMember_FallsBackWithoutRejectingEligibleMember covering the exact scenario you described.

// internal {{ member.field_name }}_calls tuple above uses for its own, unrelated, internal-only
// matching purpose). Not emitted for an overload-matching-eligible member - ReceivedCalls() is
// scoped to exactly ADR-0048's non-overloaded eligible-member set for 1.1.
internal readonly record struct {{ member.received_call_class_name }}({{ for p in member.parameters }}{{ p.fully_qualified_type_name }} {{ p.escaped_name }}{{ if !for.last }}, {{ end }}{{ end }});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard call-record properties against generated member collisions

A legal eligible method such as Foo(int __Foo_ReceivedCall) makes this render record struct __Foo_ReceivedCall(int __Foo_ReceivedCall), whose synthesized positional property has the same name as its enclosing type and fails with CS0542. Other record-generated member names can similarly collide because parameter names are copied verbatim without validation. Make the exposed property names collision-safe or report an unsupported-shape diagnostic rather than emitting uncompilable consumer code.

AGENTS.md reference: AGENTS.md:L183-L185

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 48816c8: a parameter whose name equals the record's own type name is now suffixed _Value in the record declaration only (positional construction elsewhere is order-based, not name-based, so nothing else needed changing). Added ReceivedCallRecordParameterNameCollidesWithRecordType_ConsumerCompiles covering it.

…kage-validation CI, regen API docs

Three real generator-correctness bugs found by automated PR review, each with a
new generator-fixture regression test:

- ClearCalls()/ReceivedCalls() are always-emitted, always-zero-argument bridge
  extensions exactly like Configure()/Verify(), but the CMP0023 reserved-name
  collision check only covered the latter two - an interface declaring its own
  zero-argument ClearCalls/ReceivedCalls member silently shadowed the generated
  bridge with no diagnostic. Widened the reserved-name set in TestDoubleAnalyzer.
- An eligible member's generated {FieldName}_ReceivedCall record-class name could
  collide with an unrelated sibling member's own natural field name, producing a
  real CS0102 duplicate-declaration compile error. Fixed by feeding this derived
  name into the existing derivedAuxiliaryNameOwners collision pre-pass, which
  demotes the colliding member out of matching eligibility instead.
- A parameter literally named the same as its own member's generated record type
  produced a positional record property sharing the enclosing type's name
  (CS0542). Fixed in TestDouble.scriban by suffixing that one parameter's
  declared name inside the record declaration only.

Also fixes two unrelated CI blockers surfaced on this PR:
- .github/workflows/package-validation.yaml never set -p:Version on its
  validation-only pack, defaulting to 1.0.0.0 - this started failing ApiCompat's
  CP0003 the moment nuget.org's published baseline crossed 1.0.0
  (1.1.0-preview.103, published by PR #133's merge to main). Every PR's
  package-validation would fail this way regardless of content; pinned the
  packed Version to the resolved baseline.
- docs/reference/api/ had drifted from PLAN-0063's new public members
  (CallVerifier.AtLeast/AtMost, LogVerificationBuilder.AtLeast/AtMost,
  ReturnConfig<T>.ClearObservedCalls) - regenerated via
  .github/scripts/generate-api-reference.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiVv392P3m46azTD1TpU3s
@ncipollina

Copy link
Copy Markdown
Contributor Author

@codex review commit 48816c8 specifically - it addresses your three prior findings plus two unrelated CI fixes. Please focus on:

  1. src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs (reserved-name collision, ~line 67-74 and the new _ReceivedCall entry in the derivedNames array ~line 500-509): confirm the widened CMP0023 reserved-name set (ClearCalls/ReceivedCalls added alongside Configure/Verify) and the new _ReceivedCall entry feeding into derivedAuxiliaryNameOwners actually close both gaps you flagged, with no new false-positive demotion of an unrelated member that merely shares a substring.
  2. src/Compono.Generators/Templates/TestDouble.scriban (~line 168, the record struct declaration): confirm the _Value-suffix rename for a parameter colliding with its own record's type name is correct for every parameter position, not just the single-parameter case, and that it can't itself collide with another real parameter in the same member.
  3. test/Compono.Generators.Tests/TestDoubleVerifyTests.cs (new tests ClearCallsNamedMember_ReportsCollisionDiagnostic, ReceivedCallsNamedMember_ReportsCollisionDiagnostic, ReceivedCallRecordNameCollidesWithSiblingMember_FallsBackWithoutRejectingEligibleMember, ReceivedCallRecordParameterNameCollidesWithRecordType_ConsumerCompiles): confirm these fixtures actually exercise the scenarios from your three comments, not a weaker adjacent case.
  4. .github/workflows/package-validation.yaml: sanity-check the -p:Version=${baseline} addition to pack_one doesn't mask a real breaking-change detection when BREAKING_CHANGE=true (baseline arg is still conditionally omitted in that branch, but Version is now set unconditionally whenever a baseline exists - confirm that's intentional and safe).

Not asking for a full re-review of the whole diff - just these four areas.

@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: 48816c8bf6

ℹ️ 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".

// property name; suffixed with "_Value" in that one case (Codex review, PR #134). Two real
// parameters can never already share a name (the compiler already guarantees that for the real
// member this record mirrors), so at most one parameter in this list ever needs the suffix.
internal readonly record struct {{ member.received_call_class_name }}({{ for p in member.parameters }}{{ p.fully_qualified_type_name }} {{ if p.escaped_name == member.received_call_class_name }}{{ p.escaped_name }}_Value{{ else }}{{ p.escaped_name }}{{ end }}{{ if !for.last }}, {{ end }}{{ end }});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Choose a collision-free suffix for record parameters

Fresh evidence is the legal multi-parameter shape Foo(int __Foo_ReceivedCall, int __Foo_ReceivedCall_Value): this conditional renames the first parameter to __Foo_ReceivedCall_Value, duplicating the second parameter in the generated positional record and making the consumer compilation fail. The new fixture only covers the single-parameter case, so choose a name absent from every real parameter or report a diagnostic.

AGENTS.md reference: AGENTS.md:L183-L185

Useful? React with 👍 / 👎.

…ion-free, not just naive

Codex review round 2 (PR #134) caught a real gap in the previous fix: the
scriban template's unconditional "_Value" suffix for a parameter colliding
with its own record's type name could itself collide with a second real
parameter already literally named that
(`Foo(int __Foo_ReceivedCall, int __Foo_ReceivedCall_Value)`), producing a
duplicate positional-record-property compile error.

Moved the rename computation into a new
TestDoubleMemberInfo.ReceivedCallRecordParametersText C# property, which
checks the candidate against every real parameter's own name (not just the
colliding one) and increments a numeric suffix until free
(__Foo_ReceivedCall_Value2, _Value3, ...). Wired through
TestDoubleEmitter.cs's anonymous projection model, which the first attempt
had missed - Scriban's default reflection binding only sees properties on
the wrapper anonymous object passed to Render(), not arbitrary computed
properties on the underlying TestDoubleMemberInfo record.

Added ReceivedCallRecordParameterNameCollidesWithBothRecordTypeAndItsNaiveRenameCandidate_ConsumerCompiles
covering exactly the two-parameter collision Codex identified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiVv392P3m46azTD1TpU3s
@ncipollina

Copy link
Copy Markdown
Contributor Author

@codex review commit 9f70037 specifically - it addresses your round-2 finding on the record-parameter rename.

What changed: the collision-avoidance logic moved out of the scriban template and into a new C# property, TestDoubleMemberInfo.ReceivedCallRecordParametersText (src/Compono.Generators/Models/TestDoubleMemberInfo.cs), wired through src/Compono.Generators/Emitters/TestDoubleEmitter.cs's anonymous projection model into src/Compono.Generators/Templates/TestDouble.scriban (~line 176, now a single splice with no per-parameter Scriban loop for this text).

The new algorithm: seed a reserved set with every real parameter's own EscapedName. For the one parameter (if any) whose name equals the record's own type name, try {name}_Value, then {name}_Value2, {name}_Value3, ... until the candidate isn't in reserved, then add it to reserved before moving on.

Please specifically verify:

  1. The candidate search actually covers every real parameter's name, not just the single originally-colliding one - i.e., confirm there's no path where a third parameter, or a parameter whose own name happens to already be {name}_Value2, etc., could still collide.
  2. The new regression test ReceivedCallRecordParameterNameCollidesWithBothRecordTypeAndItsNaiveRenameCandidate_ConsumerCompiles (test/Compono.Generators.Tests/TestDoubleVerifyTests.cs) actually reproduces the exact scenario from your prior comment, not a weaker adjacent one.
  3. No other code path in TestDoubleEmitter.cs/TestDouble.scriban still references the old, removed per-parameter Scriban conditional (i.e., the fix is complete, not partially reverted).

Not asking for a full re-review - just confirm this closes the gap airtight.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 9f70037896

ℹ️ 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 d2dd400 into main Sep 7, 2026
18 checks passed
@ncipollina
ncipollina deleted the feat/testdoubles-atleast-atmost-received-calls-clear-calls branch September 7, 2026 23:55
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