Skip to content

fix(solid_generator): seed cross-file registry from constructor-injected holder types - #105

Merged
nank1ro merged 2 commits into
mainfrom
fix/registry-constructor-injected-holders
Aug 25, 2026
Merged

nank1ro merged 2 commits into
mainfrom
fix/registry-constructor-injected-holders

Conversation

@nank1ro

@nank1ro nank1ro commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes #104.

Problem

_populateCrossFileTypes seeded wantedTypes only from @SolidEnvironment field types and Provider(...)/.environment<T>() call sites. A class reached solely through plain constructor injection never entered the registry, so its @SolidState reads through the injected reference stayed silently un-lowered — producing always-non-null Signal comparisons that dart fix can collapse into dead code (reproduced in a real app migration; see the issue).

Fix

Seed wantedTypes additionally from every class's instance-field and constructor-parameter declared type names, including explicitly-typed super. parameters and generic type arguments (recursively — List<AuthRepository> seeds AuthRepository). Resolution mirrors Dart's own name rules:

  • a name declared in the current unit shadows imports (cross-file attribution for it would be provably wrong — this kills a reviewer-reproduced false-positive rewrite on a same-name plain DTO),
  • import show/hide combinators are honored in the walk,
  • dart:core/dart:async SDK names are skipped (keeps the wantedTypes.isEmpty fast path effective — primitives no longer force the import walk).

SPEC.md §4.9 rule 7 updated accordingly (per the #103 precedent).

Review guide — golden fixtures (test/golden/)

Fixture Proves
cross_file_constructor_injected_holder the literal issue shape: null-guard, !-chain, write, ??=, this. all lower to .value (RED captured pre-fix)
cross_file_constructor_param_type_only constructor-param seeding alone suffices (type appears as no field)
cross_file_super_param_type_only explicitly-typed super. param seeding
cross_file_collection_generic_arg_seeding generic-argument seeding + for-in receiver rewrite
cross_file_local_shadowing_decoy local plain class + imported same-name annotated class → no rewrite (was a non-compiling false positive mid-development)
cross_file_import_hide_combinator a hide-excluded import can't claim a name
cross_file_constructor_injected_no_state negative: injected plain class → zero rewriting, no registry pollution

Known residual gaps (documented, not regressions)

  • Bare super.x shorthand carries no type in source — unrecoverable by a syntactic walk (code comment + CHANGELOG caveat).
  • An untyped field assigned only via a typed field-formal parameter resolves as dynamic under strict-inference and is a separate, pre-existing value_rewriter receiver-resolution gap — worth its own issue if wanted.
  • repos.first.field rewrite was manually verified but has no persisted fixture (CHANGELOG says so explicitly).

Verification

317/317 solid_generator tests (incl. idempotency for all new fixtures), 11/11 integration tests, dart analyze --fatal-infos clean, format clean, all example apps rebuilt with zero output drift (none use this DI shape yet). Version cut: solid_generator 3.0.0-dev.3; solid_annotations untouched.

…ted holder types

Closes #104. _populateCrossFileTypes seeded wantedTypes only from
@SolidEnvironment field types and Provider/.environment<T>() call sites,
so a class reached solely through plain constructor injection (a field
like `final AuthRepository _authRepository;`) never entered the registry
and its @SolidState reads stayed silently un-lowered — always-non-null
Signal comparisons that dart fix can collapse into dead code.

wantedTypes is now additionally seeded from every class's instance-field
and constructor-parameter declared type names (incl. explicitly-typed
super parameters and generic type arguments, recursively), with
resolution mirroring Dart's own rules: names declared in the current
unit shadow imports, import show/hide combinators are honored, and
dart:core/dart:async SDK names are skipped. Bare `super.x` shorthand
(no type in source) remains a documented syntactic-walk gap.

Seven golden fixtures cover the issue shape (reads, writes, ??=, this.),
param-only seeding, super-param, collection generics via for-in, the
local-shadowing decoy, the hide-combinator case, and a no-@SolidState
negative.
…wering

A @SolidState getter lowering to a Computed whose body reads cross-file
@SolidState state through a constructor-injected field was the one
untested combination of the #104 registry seeding with the derived-state
path. Empirically GREEN: the getter lowering already receives the seeded
registry, so both the null-guard and the null-assert chain lower to
.value inside the Computed lambda. Coverage-only — no generator change.
@nank1ro
nank1ro merged commit 878fde5 into main Aug 25, 2026
1 check passed
@nank1ro
nank1ro deleted the fix/registry-constructor-injected-holders branch August 25, 2026 15:43
nank1ro added a commit that referenced this pull request Aug 26, 2026
…umer files (#107)

* fix(solid_generator): lower cross-file @SolidState reads in pure-consumer files

Closes #106. Files with no @solid* annotation text and no provider hint
took a verbatim-passthrough fast path and never entered the pipeline, so
the #105 registry seeding never ran for them: a pure consumer's
cross-file state reads stayed silently un-lowered, and dart fix could
collapse the resulting always-non-null guards into dead code (reproduced
as a generated authentication bypass in a real router guard).

The bailout now probes the #105 syntactic seeding first and, when the
cross-file registry is non-empty, runs a dedicated plain-class lowering
pass (no Disposable/dispose synthesis) with the resolved unit BEFORE
dispose call-site injection — ordering matters: injecting first would
hand the lowering pass edited text and lose staticType receiver
resolution (loop variables), reintroducing the bug. The probe's
registries are threaded into the pipeline (no duplicate import walk);
genuinely dependency-free files keep a zero-import-walk path.

Documented residual gaps: widget-class pure consumers (SignalBuilder
placement is a larger change) and static-field-mediated DI (seeding
deliberately skips static fields).

Four golden fixtures: pure consumer (reads/writes/!.-chain), router-guard
if/else shape, provider-hint + loop-variable combo, and a solid-free
negative proving the verbatim path survives.

* fix(solid_generator): close the pure-consumer residual gaps

Widget-class pure consumers now get the SignalBuilder-wrapped build()
the @SolidEnvironment path already uses (no stateful lift — a
constructor-injected field needs no context), with a combinator-aware
import repair driven by the wrap's own emitted flag (a show/hide-
restricted flutter_solidart import is widened, never substring-guessed).
Static-field-mediated DI seeds the registry (the isStatic skip had no
recorded rationale). Bare super.x params seed from the resolved element
type on the main path; a pure consumer whose only link is a bare super.x
still misses the syntactic probe gate — documented. A new value_rewriter
tier resolves an untyped field's type from its field-formal or
initializer-list constructor parameter, bailing on conflicting
constructors. The .first collection receiver is pinned by fixture.

Both pure-consumer lowering passes are now pure edit-collectors over the
same pristine source and resolved unit, merged and applied in a single
transformation — running either pass on the other's edited text starved
it of staticType resolution and silently reintroduced the #106 guard
collapse for tier-1-only reads co-located with a widget consumer
(caught in review, RED-proven by fixture).
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.

Cross-file @SolidState reads through constructor-injected holders are silently un-lowered

1 participant