Skip to content

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

Merged
nank1ro merged 2 commits into
mainfrom
fix/pure-consumer-pipeline-entry
Aug 26, 2026
Merged

nank1ro merged 2 commits into
mainfrom
fix/pure-consumer-pipeline-entry

Conversation

@nank1ro

@nank1ro nank1ro commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #106.

Problem

The verbatim-passthrough fast path (no @Solid* annotation text, no Provider/.environment hint) meant pure consumers — files that hold a @SolidState-bearing class via plain constructor injection but declare nothing solid themselves — never entered the pipeline at all. The #105 registry seeding therefore never ran for them: their cross-file state reads stayed silently un-lowered, and dart fix's unnecessary_null_comparison/dead_code passes collapsed the resulting always-non-null guards — reproduced as a generated authentication bypass in a real app's router guard (AuthGuard.onNavigation reduced to an unconditional resolver.next()). Every #105 fixture masked the gap because each consumer carried a @SolidState control field.

Fix

  1. Probe gate: before the passthrough, run the fix(solid_generator): seed cross-file registry from constructor-injected holder types #105 syntactic seeding (same function, same shadowing/combinator/skip-set rules) on the parsed unit; enter the pipeline iff the cross-file registry is non-empty. Genuinely dependency-free files keep a zero-import-walk path; the probe's registries are threaded through so the pipeline never repeats the import walk.
  2. New lowering pass (cross_file_consumer_rewriter.dart): plain-class-only .value lowering for pure consumers — no implements Disposable, no synthesized dispose() (a naive pipeline entry would have wrongly stamped both).
  3. Pass ordering is load-bearing: lowering runs FIRST on the pristine source with the resolved unit, dispose call-site injection second — the reverse order hands the lowering pass edited text, loses staticType receiver resolution (loop variables), and reintroduces the exact bug (caught by review, RED-proven).

Review guide — fixtures (test/golden/)

Fixture Proves
cross_file_pure_consumer the literal issue shape: null-guard, !-chain, write — zero own annotations (RED: verbatim passthrough)
cross_file_pure_consumer_router_guard the auth-bypass if/else shape survives dart fix post-fix
cross_file_pure_consumer_with_provider dispose-injecting provider call site + for-in loop-variable read in ONE file: both rewrites present (RED: dart fix proposed collapsing the guard)
cross_file_pure_consumer_no_state solid-free file stays byte-identical verbatim (fast path survives)

Known residual gaps (documented in CHANGELOG + SPEC §2)

  • Widget-class pure consumers: a build() reading a cross-file signal needs SignalBuilder placement — deliberately skipped whole (byte-identical passthrough), not partially rewritten.
  • Static-field-mediated DI (Holder.instance.session): seeding deliberately skips static fields.
  • Honest cost note: hint-free files with custom-typed members now pay a bounded syntactic probe + import walk (previously zero); files with no custom-typed members keep zero cost.

Verification

327/327 solid_generator tests (4 new fixtures + idempotency), 11/11 integration tests, dart analyze --fatal-infos clean, format clean, all 5 example apps rebuild with zero fix-attributable delta. RED authenticity re-proven against the pre-#106 generator. Version cut: solid_generator 3.0.0-dev.4.

Pre-existing, unrelated: example/lib/main.dart is stale vs example/source/main.dart (a const drift predating this branch) — left untouched, worth a separate cleanup.


Update: residual gaps closed (second commit)

Per follow-up review, the residual gaps documented above are now fixed in the same PR (all part of the unreleased 3.0.0-dev.4):

  • Widget-class pure consumers — build() gets the same SignalBuilder wrap @SolidEnvironment widgets use (no stateful lift; const constructors preserved; a build with no tracked reads is correctly NOT wrapped). Imports are repaired combinator-aware: a show Signal/hide SignalBuilder import is widened based on the wrap's own emitted flag, never a URI substring guess.
  • Static-field-mediated DI (Holder.instance.session) — seeding no longer skips static fields (the skip had no recorded rationale); the receiver already resolves via the staticType tier.
  • Bare super.x — seeds from the resolved element type on the main path; the one remaining sliver (a pure consumer whose ONLY link is a bare super.x, which misses the syntactic probe gate) is documented precisely.
  • Untyped field via typed field-formal (final _service; + AuthService this._service) — new value_rewriter resolution tier, bailing on conflicting constructors.
  • .first receiver — was already green; now pinned by fixture.

Structural hardening from review (2 reproduced BLOCKERs): both pure-consumer lowering passes are now pure edit-collectors over the same pristine source + resolved unit, merged into a single application — any ordering where one pass consumed the other's edited text silently starved tier-1 resolution and reintroduced the guard-collapse bug for mixed files (fixture cross_file_pure_consumer_widget_and_static).

Suite: 339 tests (10 new fixtures across both commits + idempotency), 11/11 integration, analyze/format clean, examples byte-identical.

…umer 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.
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).
@nank1ro
nank1ro merged commit e73a2fd into main Aug 26, 2026
1 check passed
@nank1ro
nank1ro deleted the fix/pure-consumer-pipeline-entry branch August 26, 2026 08:46
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.

Files with no @Solid annotations bypass the pipeline, so cross-file @SolidState reads in pure consumers are never lowered

1 participant