Skip to content

Fix mock code gen for dependency cycles through Instantiator boundaries#219

Merged
dfed merged 4 commits intomainfrom
dfed--mock-cycle-fix
Apr 6, 2026
Merged

Fix mock code gen for dependency cycles through Instantiator boundaries#219
dfed merged 4 commits intomainfrom
dfed--mock-cycle-fix

Conversation

@dfed
Copy link
Copy Markdown
Owner

@dfed dfed commented Apr 6, 2026

Summary

Fixes mock code generation for dependency cycles through Instantiator boundaries, and prevents optional mock parameters from shadowing non-optional local bindings.

Fix: __safeDI_mock_ internal parameter label prefix

All mock method parameters now use Swift's external/internal label syntax:

public static func mock(
    player __safeDI_mock_player: Player? = nil
) -> Parent {
    let player: Player = __safeDI_mock_player ?? __safeDI_player()
}

The external label is unchanged — call-site API is unaffected (Parent.mock(player: customPlayer)). The internal __safeDI_mock_ prefix prevents optional parameters from shadowing the non-optional local bindings that child scopes reference. Without this, closure-typed defaults and builder-internal bindings would resolve to stored properties or self-reference instead of the mock parameter.

Known issue: partially-lazy cycle forward references

The partially-lazy cycle pattern (A → B → Instantiator<C> → C → A, where C receives A through a single lazy hop) generates code with forward variable references that Swift's compiler rejects: closure captures 'X' before it is declared.

This is a pre-existing production code gen bug, not introduced by this PR. The production test run_writesConvenienceExtensionOnRootOfTree_whenPartiallyLazyInstantiationCycleExists only does string comparison — it never compiles the generated output. The bug is latent in production because types involved in partially-lazy cycles are never roots (so no convenience init is generated). Mock generation exposes it because generateMock: true generates for non-root types.

The mock_optionalNotPassedAsNonOptional_whenReceivedPropertyCyclesThroughInstantiatorBoundary test documents this case but its expected output contains the same forward-reference pattern that won't compile. A proper fix requires reworking how ScopeGenerator handles partially-lazy cycles for both production and mock code gen — tracked separately.

Fully-lazy cycles (all Instantiator hops) and self-forwarding cycles work correctly.

Cycle test coverage added

Test Pattern Status
mock_optionalNotPassedAsNonOptional_... Partially-lazy (A → Instantiator<B> → B → A) Documents known issue
mock_generatedForFullyLazyInstantiationCycle All Instantiator hops Working
mock_generatedForLazySelfForwardingInstantiationCycle Self-cycle with @forwarded Working

Other fixes in this PR

  • Closure-typed default parameters now get let X = __safeDI_mock_X body bindings (no parens, since @escaping not @autoclosure)
  • defaultValueBindings and uncoveredDependencyBindings inside builder functions use __safeDI_mock_ prefix
  • CLAUDE.md updated: when updating test expected output, verify the new expected code would compile as valid Swift

Test plan

  • 762 tests pass
  • Lint clean
  • 100% coverage on modified source
  • __safeDI_mock_ prefix verified to compile via swiftc -o /dev/null (actual compilation, not just -typecheck)

🤖 Generated with Claude Code

When a child type receives its parent through an Instantiator boundary
(partially-lazy cycle), the mock code gen was passing the optional mock
parameter where a non-optional was expected. Now uses the production
cycle-breaking pattern: reconstructs the cycled type inline from
available scope bindings.

Also adds mock tests for fully-lazy cycles and self-forwarding cycles,
matching the production code gen test coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (ab4d828) to head (2dee607).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##              main      #219   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           40        40           
  Lines         6045      6055   +10     
=========================================
+ Hits          6045      6055   +10     
Files with missing lines Coverage Δ
Sources/SafeDICore/Generators/ScopeGenerator.swift 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

dfed and others added 3 commits April 6, 2026 01:17
…dowing

Mock method parameters now use `externalLabel __safeDI_mock_externalLabel:`
syntax. This prevents optional mock parameters from shadowing non-optional
local bindings, which is critical for cycles through Instantiator boundaries
where nested functions must capture the non-optional local via forward
reference.

Without this fix, a partially-lazy cycle (Parent → Instantiator<Child> →
Child → Parent) would pass Player? where Player is expected, because the
nested builder function captured the optional parameter instead of the
non-optional local.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closure-typed defaults use @escaping (not @autoclosure), so they were
previously passed directly to the init without a local binding. With
the __safeDI_mock_ prefix, the bare parameter name in the init call
resolved to the stored property instead of the mock parameter.

Now emits `let X = __safeDI_mock_X` (no parens) for closure-typed
defaults, bridging the prefixed parameter to a local.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… review rule

defaultValueBindings and uncoveredDependencyBindings inside builder
functions were generating `let X = X()` instead of `let X = __safeDI_mock_X()`,
causing the bare name to resolve to stored properties or self-reference
instead of the mock parameter. Also handles closure-typed defaults
in builders (`let X = __safeDI_mock_X` without parens).

Added CLAUDE.md rule: when updating test expected output, verify the
new expected code would compile as valid Swift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dfed dfed self-assigned this Apr 6, 2026
@dfed dfed merged commit 7c1ef2b into main Apr 6, 2026
21 of 22 checks passed
@dfed dfed deleted the dfed--mock-cycle-fix branch April 6, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant