From 2b13263636f1ab5b5cd0444f75239557218bef2c Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Tue, 25 Aug 2026 20:40:38 +0700 Subject: [PATCH 1/2] fix(solid_generator): seed cross-file registry from constructor-injected holder types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #104. _populateCrossFileTypes seeded wantedTypes only from @SolidEnvironment field types and Provider/.environment() 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. --- SPEC.md | 4 +- packages/solid_generator/CHANGELOG.md | 7 + packages/solid_generator/lib/builder.dart | 200 +++++++++++++++++- packages/solid_generator/pubspec.yaml | 2 +- .../auth_repository.dart | 14 ++ .../manager.dart | 28 +++ .../auth_repository.dart | 15 ++ .../customers_repository.dart | 46 ++++ .../plain_settings.dart | 13 ++ .../settings_reader.dart | 24 +++ .../analytics_service.dart | 12 ++ .../report_builder.dart | 35 +++ .../main.dart | 32 +++ .../right_meter.dart | 8 + .../wrong_meter.dart | 11 + .../main.dart | 43 ++++ .../remote_address.dart | 12 ++ .../auth_repository.dart | 15 ++ .../base_repository.dart | 12 ++ .../customers_repository.dart | 29 +++ .../auth_repository.g.dart | 11 + .../manager.g.dart | 24 +++ .../auth_repository.g.dart | 11 + .../customers_repository.g.dart | 35 +++ .../plain_settings.g.dart | 13 ++ .../settings_reader.g.dart | 21 ++ .../analytics_service.g.dart | 11 + .../report_builder.g.dart | 23 ++ .../main.g.dart | 22 ++ .../right_meter.g.dart | 11 + .../wrong_meter.g.dart | 11 + .../main.g.dart | 27 +++ .../remote_address.g.dart | 11 + .../auth_repository.g.dart | 11 + .../base_repository.g.dart | 12 ++ .../customers_repository.g.dart | 20 ++ .../test/integration/golden_helpers.dart | 7 + 37 files changed, 838 insertions(+), 5 deletions(-) create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_collection_generic_arg_seeding/auth_repository.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_collection_generic_arg_seeding/manager.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_holder/auth_repository.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_holder/customers_repository.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_no_state/plain_settings.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_no_state/settings_reader.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_constructor_param_type_only/analytics_service.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_constructor_param_type_only/report_builder.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/main.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/right_meter.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/wrong_meter.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_local_shadowing_decoy/main.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_local_shadowing_decoy/remote_address.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/auth_repository.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/base_repository.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/customers_repository.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_collection_generic_arg_seeding/auth_repository.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_collection_generic_arg_seeding/manager.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_holder/auth_repository.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_holder/customers_repository.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_no_state/plain_settings.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_no_state/settings_reader.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_constructor_param_type_only/analytics_service.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_constructor_param_type_only/report_builder.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/main.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/right_meter.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/wrong_meter.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_local_shadowing_decoy/main.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_local_shadowing_decoy/remote_address.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/auth_repository.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/base_repository.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/customers_repository.g.dart diff --git a/SPEC.md b/SPEC.md index 6df4fde..5017ffa 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1087,7 +1087,9 @@ Rules: - **The builder's no-annotation fast path** (a file with zero `@Solid*`-annotated classes) hands the generator a fully-RESOLVED `CompilationUnit`. Clause 1 uses `InterfaceType.lookUpMethod`; clause 2 is checked BOTH via the name-based registry below AND, secondarily, via the resolved element's own annotation metadata. The registry is the primary cross-file mechanism: it walks the importing file's imports in order, and a same-named class with no `@SolidState` members no longer halts the search — the wanted type name stays outstanding until an actually-annotated match is found (or every import is exhausted), so a non-annotated decoy earlier in import order can't strand a real annotated class in a later import unrecognized. The secondary resolved-element-annotation check remains a belt-and-suspenders backstop for cases the registry doesn't reach; it is not the mechanism the design relies on. A resolved-but-unproven type is clause 3 (skip): resolution IS visibility. - **The main lowering path** (a file with at least one `@Solid*`-annotated class) re-parses the fully ASSEMBLED, already-lowered output text with NO resolver at all. Clause 1 is a same-file-only AST check (a Solid-lowered class's synthesized `dispose()` is already textually present in that reassembled text by the time this check runs). Clause 2 is name-based only, via the registry below. A type whose declaration lives in another file and ISN'T in the registry — e.g. a plain, dispose-less repository imported and provided but never itself consuming anything — cannot be proven plain on this path and falls to clause 4 (inject); `dispose: null` is required there even though the type genuinely has no `dispose()`. - Clause 2's registry is the SAME class-name → reactive-member-name map (`classRegistry`) the builder threads through the `.value` cross-class rewrite (§5.1) — populated same-file by a fast member-scan and cross-file by walking imports (the resolver pass documented under §3.6's env-field rule), extended to also seed from every type created at a `Provider(...)` / `.environment()` call site in the file (not just `@SolidEnvironment` field types), so a controller that's provided but never consumed via `@SolidEnvironment` still gets recognized. A type name enters the registry ONLY when its declaration carries at least one `@SolidState` field or getter. Matching is by SIMPLE class name, not library identity — two distinct types sharing a name across different libraries could in principle collide; a pre-existing risk of the name-based registry design, not newly introduced by this rule. + Clause 2's registry is the SAME class-name → reactive-member-name map (`classRegistry`) the builder threads through the `.value` cross-class rewrite (§5.1) — populated same-file by a fast member-scan and cross-file by walking imports (the resolver pass documented under §3.6's env-field rule), extended to also seed from every type created at a `Provider(...)` / `.environment()` call site in the file (not just `@SolidEnvironment` field types), so a controller that's provided but never consumed via `@SolidEnvironment` still gets recognized. It is further extended to seed from the declared type name of every class's instance field and constructor parameter in the file — the plain constructor-injection DI shape (`CustomersRepository({required AuthRepository authRepository})`, `final AuthRepository _authRepository;`), which has no `@SolidEnvironment` field and creates nothing at a `Provider(...)` / `.environment()` call site, so neither of the other two rules ever seeds it (issue #104). A type name enters the registry ONLY when its declaration carries at least one `@SolidState` field or getter; a candidate name that matches nothing found is harmless. + + Two rules narrow the cross-file import walk to mirror Dart's own name resolution, closing collision windows the annotation-blind seeding above would otherwise open: a simple name that the CURRENT file itself declares as a class/enum/mixin/etc. is dropped from the wanted set before the import walk starts — a local top-level declaration always shadows a same-name import (no error, no ambiguity), so cross-file attribution for that name is provably wrong and the import walk never even looks for it; and each import's `show`/`hide` combinators are honored — an import that hides the wanted name, or carries a `show` list that doesn't include it, cannot be credited as that name's source and is skipped for that name (its other names are unaffected). Matching is otherwise by SIMPLE class name, not library identity — two distinct types sharing a name across two DIFFERENT imported libraries, neither of them local to the current file and neither excluded by a combinator, could in principle still collide; a residual risk of the name-based registry design, not resolved by the two rules above. The injected `provider.dispose()` always compiles for Solid-lowered types because Section 10 attaches `implements Disposable` and a synthesized `dispose()` to every annotated class. For non-Solid types, the user declares `void dispose()` (or a known disposable base) on the source class for clause 1 to fire; otherwise nothing is injected (clause 3) unless the type is cross-file and unproven (clause 4), and the user may always opt in or out by writing `dispose:` explicitly. diff --git a/packages/solid_generator/CHANGELOG.md b/packages/solid_generator/CHANGELOG.md index 0f21543..b80eac1 100644 --- a/packages/solid_generator/CHANGELOG.md +++ b/packages/solid_generator/CHANGELOG.md @@ -1,3 +1,10 @@ +## 3.0.0-dev.3 + +- **FIX**: The cross-file class registry (`_populateCrossFileTypes`) now seeds `wantedTypes` from the declared type names of every class's instance fields and constructor parameters, not just `@SolidEnvironment` field types and `Provider(...)`/`.environment()` call sites. A file that only *constructor-receives* a `@SolidState`-bearing class — the plain DI shape, e.g. `CustomersRepository({required AuthRepository authRepository})` storing `final AuthRepository _authRepository;`, with no `@SolidEnvironment` field and no same-file `.environment()`/`Provider()` call site — previously left `classRegistry` empty for that type, so cross-class `.value` reads through it (`_authRepository.session`) were silently un-lowered: no compile error, just an always-non-null `Signal` object that `dart fix`'s `unnecessary_null_comparison` could collapse into dead code (#104). +- **FIX**: The seeding above (and, defensively, every other seeding path into `wantedTypes`) now mirrors Dart's own name-resolution rules instead of blindly matching on simple class name. A simple name that the CURRENT file itself declares as a class/enum/mixin is dropped from the wanted set before the cross-file import walk starts — a local top-level declaration always shadows a same-name import, so attributing an unrelated imported class's reactive members to it was provably wrong (e.g. a file with its own plain `class Address` plus an unrelated, unconnected `@SolidState`-annotated `class Address` imported from elsewhere previously emitted a non-compiling `address.line1.value` against the local class). Each import's `show`/`hide` combinators are also now honored: an import that hides the wanted name, or `show`s a list that excludes it, can no longer be credited as that name's source, closing a second, narrower collision window. +- **FIX**: Constructor parameters using the explicit-typed `super.` shorthand (`Foo(AuthRepository super.repo)`) now seed `wantedTypes` the same as a plain or field-formal parameter. The far more common *bare* `super.repo` (no type written) is a known, accepted gap: the type isn't present in the source at that position at all — recovering it would require resolving `repo` against the superclass's matching field/parameter, which this syntactic AST walk does not do — so a bare-shorthand `super.` parameter still isn't a seeding source. +- **FIX**: A field or constructor parameter declared with a generic collection type (`final List repos;`) now seeds `wantedTypes` from every type argument, at every nesting level, not just the outer container name (`List`). This lets the existing resolved-static-type rewrite tiers recognize collection-derived receivers whose element type is Solid-lowered — covered by a golden fixture for a `for (final r in repos) { r.field }` loop variable; a `repos.first.field` receiver was verified manually to rewrite the same way (same resolved-type mechanism) but has no persisted fixture, and deeper nesting (`Map>` and beyond) is mechanically identical but likewise untested. Container names themselves (`List`, `Map`, `String`, and other `dart:core`/`dart:async` SDK types) are now filtered out of every seed in the new constructor-injection/field loop before being added, which also fixes a performance regression where annotation-blind seeding of primitive types on nearly every field defeated the `wantedTypes.isEmpty` fast-path. + ## 3.0.0-dev.2 - **FIX**: Cross-class `.value` rewrite now resolves constructor-injected instance fields (`final AuthRepository _authRepository;`), not just method/function parameters and `@SolidEnvironment` fields. Previously a bare instance field receiver silently kept its unlowered form, producing always-true null checks and compile errors against the unboxed `Signal` payload. Also covers a `this.`-prefixed receiver (`this._authRepository.session`) — `this.` parses as a distinct AST shape from the bare `_authRepository.session` form and previously fell through unrewritten. diff --git a/packages/solid_generator/lib/builder.dart b/packages/solid_generator/lib/builder.dart index 56794d2..d66878f 100644 --- a/packages/solid_generator/lib/builder.dart +++ b/packages/solid_generator/lib/builder.dart @@ -937,9 +937,11 @@ RewriteResult _rewriteClass( /// Resolver pass for the cross-file slice of the chain-aware rule. For each /// `@SolidEnvironment` field whose declared type is NOT defined in the -/// current source file, OR each type name in [extraWantedTypes] (the types +/// current source file, each type name in [extraWantedTypes] (the types /// created at a `Provider(...)` / `.environment()` call site in this -/// file — see [collectProviderCreatedTypeNames]), walk the imported +/// file — see [collectProviderCreatedTypeNames]), OR the declared type name +/// of any class's instance field / constructor parameter in this file (the +/// plain constructor-injection DI shape — see issue #104), walk the imported /// `source/.dart` file(s) via `BuildStep.resolver.compilationUnitFor` /// and merge any `@SolidState` members of the matching class declaration /// into [classRegistry] (and the collection-subset into @@ -948,7 +950,13 @@ RewriteResult _rewriteClass( /// The two registries are mutated in place. Same-file types take precedence: /// when a type name is already present, the cross-file pass does NOT /// overwrite it (in-file source is always the source of truth for the -/// current build). +/// current build). More generally, any simple name this unit itself +/// declares (class/enum/mixin/…) is dropped from the wanted set before the +/// import walk even starts — mirroring Dart's own name resolution, where a +/// local top-level declaration always shadows a same-name import — and each +/// import's `show`/`hide` combinators are honored so an import cannot be +/// credited as a name's source when it explicitly excludes that name (see +/// [_importExposesName]). /// /// `package:` imports of the **current package** are redirected from `lib/` /// to `source/` because the user's `@SolidState` annotations live on the @@ -991,6 +999,76 @@ Future _populateCrossFileTypes( if (classRegistry.containsKey(typeText)) continue; wantedTypes.add(typeText); } + // Additionally seed `wantedTypes` from the declared type names of every + // class's instance fields and constructor parameters — the plain + // constructor-injection DI shape (`CustomersRepository({required + // AuthRepository authRepository})`, `final AuthRepository + // _authRepository;`) has no `@SolidEnvironment` field and creates nothing + // at a `Provider(...)` / `.environment()` call site, so neither rule + // above ever seeds it — see issue #104. Candidates are annotation-blind + // simple type names, exactly like the two seeding rules above; a name that + // doesn't match any `@SolidState`-bearing class among the resolved imports + // below is harmless — the import walk only writes a `classRegistry` entry + // when it finds `@SolidState` members on the matching class. Generic type + // arguments are seeded recursively (`List` seeds both + // `List` and `AuthRepository`, `Map>` seeds + // all three) via [_seedWantedTypeRecursive] — `List`/`Map`/`String` are + // filtered out by [_coreSdkTypeNames] there, so only the payload type + // reaches [wantedTypes]. This enables the resolved-type-driven cross-class + // rewrite tiers in `value_rewriter.dart` to fire on collection-derived + // receivers whose static type resolves to the payload class (e.g. a + // `for (final c in repos) { c.field }` loop variable, or `repos.first`) — + // see issue #104 fix review, finding 4. + for (final decl in unit.declarations) { + if (decl is! ClassDeclaration) continue; + for (final member in decl.members) { + if (member is FieldDeclaration) { + if (member.isStatic) continue; + final type = member.fields.type; + if (type is! NamedType) continue; + _seedWantedTypeRecursive(type, wantedTypes, classRegistry); + } else if (member is ConstructorDeclaration) { + for (final param in member.parameters.parameters) { + final inner = param is DefaultFormalParameter + ? param.parameter + : param; + final TypeAnnotation? paramType; + if (inner is SimpleFormalParameter) { + paramType = inner.type; + } else if (inner is FieldFormalParameter) { + paramType = inner.type; + } else if (inner is SuperFormalParameter) { + // Only the explicit-type-annotation shape (`Foo(AuthRepository + // super.repo)`) is recoverable here: `inner.type` is populated + // straight from the source annotation, same as the two branches + // above. The far more common bare-shorthand form (`super.repo`, + // no type written) carries NO type annotation in the source at + // all — the type is only knowable by resolving `repo` against + // the superclass's matching parameter/field, which this + // syntactic AST walk does not do. That shape is a known, + // accepted gap (see CHANGELOG): such a parameter simply isn't a + // wantedTypes candidate from this loop, same as if the class had + // no matching field/param seeding it at all. + paramType = inner.type; + } else { + paramType = null; + } + if (paramType is! NamedType) continue; + _seedWantedTypeRecursive(paramType, wantedTypes, classRegistry); + } + } + } + } + // Local declarations always shadow same-name imports (standard Dart + // name-resolution: no error, no ambiguity — the current library's own + // top-level declaration simply wins). So a simple name that this unit + // itself declares as a class/enum/mixin/etc. can NEVER be the wanted + // type's cross-file source, no matter what any import brings in under + // that same simple name — see issue #104 fix review, finding 1 (same- + // simple-name shadowing collision: a local plain `class Address` plus an + // unrelated imported `@SolidState`-annotated `class Address` elsewhere + // must not attribute the import's reactive fields to the local class). + wantedTypes.removeAll(_collectDeclaredTypeNames(unit)); if (wantedTypes.isEmpty) return; for (final directive in unit.directives.whereType()) { @@ -1021,6 +1099,7 @@ Future _populateCrossFileTypes( if (decl is! ClassDeclaration) continue; final className = decl.name.lexeme; if (!wantedTypes.contains(className)) continue; + if (!_importExposesName(directive, className)) continue; final scalarNames = {}; final collectionNames = {}; final fieldTypeTexts = {}; @@ -1150,6 +1229,121 @@ Set _collectDeclaredTypeNames(CompilationUnit unit) { return names; } +/// True when [directive]'s `show`/`hide` combinators (if any) allow [name] +/// to be brought into scope by that import — i.e. the directive cannot be +/// [name]'s source when this returns false. An import may carry multiple +/// combinators (`show A hide B` is legal, if unusual); ALL of them must +/// agree the name is visible. +/// +/// Used by [_populateCrossFileTypes]'s import walk so a `hide Foo` (or a +/// `show` list that excludes `Foo`) is honored — mirrors Dart's own +/// combinator semantics and protects every seeding path (same-file +/// `@SolidEnvironment` fields, `Provider(...)`/`.environment()` call +/// sites, and the constructor-injection seeding added for issue #104) from +/// misattributing a same-simple-name class the import explicitly excludes. +bool _importExposesName(ImportDirective directive, String name) { + for (final combinator in directive.combinators) { + if (combinator is ShowCombinator) { + if (!combinator.shownNames.any((id) => id.name == name)) return false; + } else if (combinator is HideCombinator) { + if (combinator.hiddenNames.any((id) => id.name == name)) return false; + } + } + return true; +} + +/// `dart:core` / `dart:async` simple type names a user class would never +/// legitimately shadow. Checked by [_seedWantedTypeRecursive] before adding +/// a candidate to `wantedTypes` in the constructor-injection / instance- +/// field seeding loop added for issue #104 (the two PRE-EXISTING seeding +/// paths — `@SolidEnvironment` fields and `Provider`/`.environment()` +/// call sites — are left untouched, per finding 5 of the fix review: those +/// are already annotation- or call-site-scoped and rarely fire on SDK +/// names). +/// +/// Without this filter, nearly every annotated file would seed `String`, +/// `int`, `bool`, etc. from ordinary field/parameter declarations, defeating +/// the `wantedTypes.isEmpty` early return below and forcing a wasted +/// resolve-and-scan of every import for a name no import will ever satisfy +/// (the SDK carries no `@SolidState` annotations). +const Set _coreSdkTypeNames = { + 'int', + 'double', + 'num', + 'bool', + 'String', + 'List', + 'Map', + 'Set', + 'Iterable', + 'Iterator', + 'Object', + 'Function', + 'Never', + 'Null', + 'dynamic', + 'Future', + 'FutureOr', + 'Stream', + 'StreamSubscription', + 'StreamController', + 'Duration', + 'DateTime', + 'Symbol', + 'Type', + 'BigInt', + 'RegExp', + 'RegExpMatch', + 'Uri', + 'StringBuffer', + 'StringSink', + 'Timer', + 'Comparable', + 'Pattern', + 'Match', + 'Runes', + 'StackTrace', + 'Exception', + 'Error', + 'Record', + 'Completer', + 'Zone', + 'Sink', + 'EventSink', + 'WeakReference', + 'Expando', +}; + +/// Adds [type]'s simple name — and, recursively, the simple name of every +/// generic type argument at every nesting level — to [wantedTypes], subject +/// to the same two guards the pre-existing seeding call sites apply inline: +/// skip names already resolved in [classRegistry], and (new for issue #104 +/// finding 4/5) skip [_coreSdkTypeNames]. +/// +/// `List` seeds `AuthRepository` (not `List`, filtered); +/// `Map>` seeds only `AuthRepository` (both +/// `Map` and `String` are filtered, `List` is filtered, `AuthRepository` +/// survives). A candidate that matches nothing on the subsequent import walk +/// is harmless — see the loop's own doc comment. +void _seedWantedTypeRecursive( + NamedType type, + Set wantedTypes, + Map> classRegistry, +) { + final typeText = type.name.lexeme; + if (!_coreSdkTypeNames.contains(typeText) && + !classRegistry.containsKey(typeText)) { + wantedTypes.add(typeText); + } + final args = type.typeArguments?.arguments; + if (args == null) return; + for (final arg in args) { + if (arg is NamedType) { + _seedWantedTypeRecursive(arg, wantedTypes, classRegistry); + } + } +} + /// Translates a `source/` AssetId to its `lib/` sibling. The /// inverse of [_resolveImportToSourceAsset]'s `lib/` → `source/` redirect. /// Passes non-`source/` AssetIds through unchanged. diff --git a/packages/solid_generator/pubspec.yaml b/packages/solid_generator/pubspec.yaml index 64f7625..0859c86 100644 --- a/packages/solid_generator/pubspec.yaml +++ b/packages/solid_generator/pubspec.yaml @@ -1,6 +1,6 @@ name: solid_generator description: Solid source-to-lib code generator for Flutter reactive state. -version: 3.0.0-dev.2 +version: 3.0.0-dev.3 homepage: https://solid.mariuti.com repository: https://github.com/nank1ro/solid issue_tracker: https://github.com/nank1ro/solid/issues diff --git a/packages/solid_generator/test/golden/inputs/cross_file_collection_generic_arg_seeding/auth_repository.dart b/packages/solid_generator/test/golden/inputs/cross_file_collection_generic_arg_seeding/auth_repository.dart new file mode 100644 index 0000000..c5f8699 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_collection_generic_arg_seeding/auth_repository.dart @@ -0,0 +1,14 @@ +// Cross-file `@SolidState` host for the generic-type-argument seeding +// regression (issue #104 fix review, finding 4). `manager.dart` reaches +// this class ONLY as the payload type of a `List` field — +// no `@SolidEnvironment` field, no plain `AuthRepository`-typed field/param, +// no `.environment()`/`Provider()` call site — so the cross-file registry +// seeding in `builder.dart::_populateCrossFileTypes` must recurse into the +// declared `List` field's type argument to recognize `AuthRepository` as +// Solid-lowered. +import 'package:solid_annotations/solid_annotations.dart'; + +class AuthRepository { + @SolidState() + String? session; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_collection_generic_arg_seeding/manager.dart b/packages/solid_generator/test/golden/inputs/cross_file_collection_generic_arg_seeding/manager.dart new file mode 100644 index 0000000..770c56f --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_collection_generic_arg_seeding/manager.dart @@ -0,0 +1,28 @@ +// Generic-type-argument seeding regression fixture (issue #104 fix review, +// finding 4): `Manager` receives `AuthRepository` only wrapped in +// `List` — the declared type's simple name is `List`, so +// the constructor-injection seeding loop must recurse into the type +// argument to find `AuthRepository`. Before the fix, only `List` was seeded +// (and even that was later filtered as an SDK name — see finding 5), so +// `AuthRepository` never entered `wantedTypes` and the for-in loop variable +// read below stayed un-lowered. +import 'package:solid_annotations/solid_annotations.dart'; + +import 'auth_repository.dart'; + +class Manager { + Manager(this.repos); + + final List repos; + + @SolidState() + int loadCount = 0; + + bool anyHasSession() { + loadCount = loadCount + 1; + for (final repo in repos) { + if (repo.session != null) return true; + } + return false; + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_holder/auth_repository.dart b/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_holder/auth_repository.dart new file mode 100644 index 0000000..f347d55 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_holder/auth_repository.dart @@ -0,0 +1,15 @@ +// Cross-file `@SolidState` host for the constructor-injected-holder +// regression (issue #104). `customers_repository.dart` reaches this class +// ONLY through a constructor-injected field — no `@SolidEnvironment` field +// and no same-file `.environment()` / `Provider()` call site anywhere in +// this fixture — so the cross-file registry seeding in +// `builder.dart::_populateCrossFileTypes` must pick up `AuthRepository` from +// the consumer's constructor parameter / instance field declared type to +// recognize it as Solid-lowered. + +import 'package:solid_annotations/solid_annotations.dart'; + +class AuthRepository { + @SolidState() + String? session; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_holder/customers_repository.dart b/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_holder/customers_repository.dart new file mode 100644 index 0000000..1768f87 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_holder/customers_repository.dart @@ -0,0 +1,46 @@ +// Plain constructor-injection DI shape reported in issue #104: +// `CustomersRepository` never uses `@SolidEnvironment` or +// `.environment()`/`Provider()` — it just receives `AuthRepository` through +// its constructor and stores it as a plain field. Before the fix, +// `_authRepository.session` reads were silently un-lowered (no `.value`) +// because `_populateCrossFileTypes` never seeded `AuthRepository` into +// `wantedTypes`, so `classRegistry["AuthRepository"]` was empty when this +// file was processed. +// +// Covers a null-check guard (`hasSession`), a `!`-chain access +// (`sessionLength`), a bare assignment (`clearSession`), a `??=` +// compound-assign (`ensureSession`), and the `this..` +// receiver shape (`hasSessionViaThis`). +// ignore_for_file: unnecessary_this + +import 'package:solid_annotations/solid_annotations.dart'; + +import 'auth_repository.dart'; + +class CustomersRepository { + CustomersRepository(this._authRepository); + + final AuthRepository _authRepository; + + @SolidState() + int loadCount = 0; + + bool hasSession() { + loadCount = loadCount + 1; + return _authRepository.session != null; + } + + int? sessionLength() => _authRepository.session!.length; + + void clearSession() { + _authRepository.session = null; + } + + void ensureSession() { + _authRepository.session ??= 'anon'; + } + + bool hasSessionViaThis() { + return this._authRepository.session != null; + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_no_state/plain_settings.dart b/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_no_state/plain_settings.dart new file mode 100644 index 0000000..ed53a2a --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_no_state/plain_settings.dart @@ -0,0 +1,13 @@ +// Negative-fixture companion for `settings_reader.dart`: `PlainSettings` +// carries no `@Solid*` annotations at all. Reached from +// `settings_reader.dart` only via a constructor-injected field — the new +// constructor-param/field-type seeding introduced for issue #104 still adds +// `PlainSettings` to `wantedTypes` (the seeding is annotation-blind by +// design; it only proposes the receiver's type name as a candidate), but +// the subsequent import walk finds zero `@SolidState` members here, so +// `classRegistry` never gains a `PlainSettings` entry and `_settings.volume` +// must stay byte-identical. + +class PlainSettings { + int volume = 50; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_no_state/settings_reader.dart b/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_no_state/settings_reader.dart new file mode 100644 index 0000000..352b05e --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_constructor_injected_no_state/settings_reader.dart @@ -0,0 +1,24 @@ +// `SettingsReader` itself carries a `@SolidState` field so this file is +// forced through the full parse-and-rewrite pipeline (otherwise the +// builder's `hasSolidAnnotation` fast-path would skip parsing entirely and +// this fixture would prove nothing about the new seeding). `PlainSettings` +// — reached only via the constructor-injected `_settings` field — has no +// `@Solid*` annotations, so `_settings.volume` must stay byte-identical. + +import 'package:solid_annotations/solid_annotations.dart'; + +import 'plain_settings.dart'; + +class SettingsReader { + SettingsReader(this._settings); + + final PlainSettings _settings; + + @SolidState() + int readCount = 0; + + int readVolume() { + readCount = readCount + 1; + return _settings.volume; + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_constructor_param_type_only/analytics_service.dart b/packages/solid_generator/test/golden/inputs/cross_file_constructor_param_type_only/analytics_service.dart new file mode 100644 index 0000000..a6612a1 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_constructor_param_type_only/analytics_service.dart @@ -0,0 +1,12 @@ +// Cross-file `@SolidState` host reached ONLY through a plain (simple) +// constructor parameter (see `report_builder.dart`) — the class stores no +// field of this type at all, so the builder's field-declared-type seeding +// alone cannot see `AnalyticsService`; only the constructor-parameter +// seeding introduced for issue #104 reaches it. + +import 'package:solid_annotations/solid_annotations.dart'; + +class AnalyticsService { + @SolidState() + int eventCount = 0; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_constructor_param_type_only/report_builder.dart b/packages/solid_generator/test/golden/inputs/cross_file_constructor_param_type_only/report_builder.dart new file mode 100644 index 0000000..8a9ecd5 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_constructor_param_type_only/report_builder.dart @@ -0,0 +1,35 @@ +// `AnalyticsService`'s name appears in this file ONLY as a constructor +// parameter type and as a same-class METHOD parameter type — never as any +// field's declared type, `@SolidEnvironment` field, or `Provider(...)` / +// `.environment()` call site. Before the fix, `_populateCrossFileTypes` +// seeded `wantedTypes` from env fields and Provider/`.environment()` call +// sites only, so `classRegistry` never learned about `AnalyticsService` +// here and `summarize`'s `service.eventCount` read — whose receiver type is +// resolved by `value_rewriter.dart`'s existing parameter-typed tier, which +// needs no seeding of its own, only a populated `classRegistry` entry — +// stayed silently un-lowered. Constructor-parameter seeding is the ONLY +// thing that closes that gap for this file: there is no field of type +// `AnalyticsService` anywhere to seed from instead. +// +// The constructor stores a `String` derived from `service`, not `service` +// itself, so the file genuinely has no `AnalyticsService`-typed field. + +import 'package:solid_annotations/solid_annotations.dart'; + +import 'analytics_service.dart'; + +class ReportBuilder { + ReportBuilder(AnalyticsService service) : _label = service.toString(); + + final String _label; + + @SolidState() + int reportsBuilt = 0; + + int summarize(AnalyticsService service) { + reportsBuilt = reportsBuilt + 1; + return service.eventCount; + } + + String describe() => _label; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/main.dart b/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/main.dart new file mode 100644 index 0000000..6662108 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/main.dart @@ -0,0 +1,32 @@ +// Regression fixture for issue #104 fix review, finding 1b: an import that +// `hide`s the wanted type name must never be credited as that name's +// cross-file source, even if it declares an `@SolidState`-bearing class of +// the same simple name. `wrong_meter.dart` is imported FIRST but with +// `hide Meter`, so `Meter` in this file unambiguously resolves to +// `right_meter.dart`'s class (standard Dart combinator semantics) — the +// cross-file registry seeding in `builder.dart::_populateCrossFileTypes` +// must honor that and skip `wrong_meter.dart` when searching for `Meter`. +// +// `hide Meter` hides the only name `wrong_meter.dart` declares, so nothing +// from that import is actually referenced here — the import itself is the +// fixture (its presence, and the builder's handling of it, is what's under +// test), not any name it brings into scope. +// ignore_for_file: unused_import +import 'package:solid_annotations/solid_annotations.dart'; + +import 'wrong_meter.dart' hide Meter; +import 'right_meter.dart'; + +class Display { + Display(this.meter); + + final Meter meter; + + @SolidState() + int loadCount = 0; + + int describe() { + loadCount = loadCount + 1; + return meter.reading; + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/right_meter.dart b/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/right_meter.dart new file mode 100644 index 0000000..23d8cf4 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/right_meter.dart @@ -0,0 +1,8 @@ +// The REAL `Meter` that `main.dart`'s `hide Meter` on the wrong_meter.dart +// import leaves visible. See issue #104 fix review, finding 1b. +import 'package:solid_annotations/solid_annotations.dart'; + +class Meter { + @SolidState() + int reading = 0; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/wrong_meter.dart b/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/wrong_meter.dart new file mode 100644 index 0000000..c9cc259 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_import_hide_combinator/wrong_meter.dart @@ -0,0 +1,11 @@ +// The HIDDEN decoy: `main.dart` imports this with `hide Meter`, so this +// class must never be credited as `Meter`'s cross-file `@SolidState` +// source — despite sharing the simple name and carrying its own +// (differently named) reactive field. See issue #104 fix review, +// finding 1b. +import 'package:solid_annotations/solid_annotations.dart'; + +class Meter { + @SolidState() + int wrongField = 0; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_local_shadowing_decoy/main.dart b/packages/solid_generator/test/golden/inputs/cross_file_local_shadowing_decoy/main.dart new file mode 100644 index 0000000..9d57e6e --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_local_shadowing_decoy/main.dart @@ -0,0 +1,43 @@ +// BLOCKER regression fixture (issue #104 fix review, finding 1): this file +// declares its OWN plain `class Address` (zero `@SolidState` members) and +// holds an instance of it as a constructor-injected field on `Shipping`, +// while ALSO importing an unrelated file that happens to declare a +// DIFFERENT, `@SolidState`-annotated `class Address`. +// +// Dart's name-resolution rule — a top-level declaration in the current +// library always shadows a same-name imported declaration, with no error — +// means `Address` inside this file unambiguously refers to the LOCAL plain +// class. `Shipping.address.line1` must stay untouched by the reactive +// rewrite. +// +// Before the fix: `_populateCrossFileTypes` seeded `wantedTypes` with +// `Address` purely from `Shipping`'s constructor-parameter declared type +// name, then the cross-file import walk (blind to local shadowing) matched +// `remote_address.dart`'s reactive `Address.line1` to that name and +// populated `classRegistry['Address']`, producing a non-compiling +// `address.line1.value` rewrite against the local plain field. +// ignore_for_file: unused_import + +import 'package:solid_annotations/solid_annotations.dart'; + +import 'remote_address.dart'; + +class Address { + Address(this.line1); + + final String line1; +} + +class Shipping { + Shipping(this.address); + + final Address address; + + @SolidState() + int loadCount = 0; + + String describe() { + loadCount = loadCount + 1; + return address.line1; + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_local_shadowing_decoy/remote_address.dart b/packages/solid_generator/test/golden/inputs/cross_file_local_shadowing_decoy/remote_address.dart new file mode 100644 index 0000000..3618df9 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_local_shadowing_decoy/remote_address.dart @@ -0,0 +1,12 @@ +// Unrelated cross-file class that happens to share the simple name +// `Address` with `main.dart`'s own plain class. `@SolidState`-annotated so, +// absent the local-declaration-shadowing fix, the cross-file import walk in +// `builder.dart::_populateCrossFileTypes` wrongly attributes this class's +// reactive `line1` field to `main.dart`'s LOCAL plain `Address` class +// (BLOCKER regression, issue #104 fix review, finding 1). +import 'package:solid_annotations/solid_annotations.dart'; + +class Address { + @SolidState() + String? line1; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/auth_repository.dart b/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/auth_repository.dart new file mode 100644 index 0000000..8b22f2b --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/auth_repository.dart @@ -0,0 +1,15 @@ +// Cross-file `@SolidState` host for the super-formal-parameter regression +// (issue #104 fix review, finding 3). `customers_repository.dart` reaches +// this class ONLY through an explicitly-typed super-initializer parameter +// (`AuthRepository super.authRepository`) — no `@SolidEnvironment` field, no +// plain constructor-injected field, no `.environment()`/`Provider()` call +// site — so the cross-file registry seeding in +// `builder.dart::_populateCrossFileTypes` must pick up `AuthRepository` from +// the super-formal-parameter's declared type to recognize it as +// Solid-lowered. +import 'package:solid_annotations/solid_annotations.dart'; + +class AuthRepository { + @SolidState() + String? session; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/base_repository.dart b/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/base_repository.dart new file mode 100644 index 0000000..80cf72e --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/base_repository.dart @@ -0,0 +1,12 @@ +// Plain, unannotated base class that owns the `authRepository` field. +// `customers_repository.dart` never redeclares this field — it only +// forwards to it through an explicitly-typed super-initializer parameter — +// so any read of `authRepository` in the subclass is an INHERITED-member +// read. See issue #104 fix review, finding 3. +import 'auth_repository.dart'; + +class BaseRepository { + BaseRepository(this.authRepository); + + final AuthRepository authRepository; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/customers_repository.dart b/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/customers_repository.dart new file mode 100644 index 0000000..8c03c59 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_super_param_type_only/customers_repository.dart @@ -0,0 +1,29 @@ +// Explicit-typed `super.` formal parameter DI shape (issue #104 fix review, +// finding 3): `CustomersRepository`'s constructor receives `AuthRepository` +// only through `AuthRepository super.authRepository`, forwarding it to +// `BaseRepository`'s constructor. `authRepository` is never redeclared as a +// field here — it's read as an inherited member. Before the fix, +// `_populateCrossFileTypes` had no branch for `SuperFormalParameter`, so +// `AuthRepository` was never seeded into `wantedTypes` and +// `authRepository.session` reads were silently un-lowered. +import 'package:solid_annotations/solid_annotations.dart'; + +import 'auth_repository.dart'; +import 'base_repository.dart'; + +class CustomersRepository extends BaseRepository { + // The explicit `AuthRepository` type annotation is the shape under test — + // it's what makes this parameter recoverable by a syntactic AST walk (see + // the file comment above). Suppress the lint that would otherwise flag it + // as redundant. + // ignore: type_init_formals + CustomersRepository(AuthRepository super.authRepository); + + @SolidState() + int loadCount = 0; + + bool hasSession() { + loadCount = loadCount + 1; + return authRepository.session != null; + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_collection_generic_arg_seeding/auth_repository.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_collection_generic_arg_seeding/auth_repository.g.dart new file mode 100644 index 0000000..b835061 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_collection_generic_arg_seeding/auth_repository.g.dart @@ -0,0 +1,11 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class AuthRepository implements Disposable { + final session = Signal(null, name: 'session'); + + @override + void dispose() { + session.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_collection_generic_arg_seeding/manager.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_collection_generic_arg_seeding/manager.g.dart new file mode 100644 index 0000000..5bd2bba --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_collection_generic_arg_seeding/manager.g.dart @@ -0,0 +1,24 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import 'auth_repository.dart'; + +class Manager implements Disposable { + Manager(this.repos); + + final List repos; + + final loadCount = Signal(0, name: 'loadCount'); + + bool anyHasSession() { + loadCount.value = loadCount.value + 1; + for (final repo in repos) { + if (repo.session.value != null) return true; + } + return false; + } + + @override + void dispose() { + loadCount.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_holder/auth_repository.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_holder/auth_repository.g.dart new file mode 100644 index 0000000..b835061 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_holder/auth_repository.g.dart @@ -0,0 +1,11 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class AuthRepository implements Disposable { + final session = Signal(null, name: 'session'); + + @override + void dispose() { + session.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_holder/customers_repository.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_holder/customers_repository.g.dart new file mode 100644 index 0000000..96cea04 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_holder/customers_repository.g.dart @@ -0,0 +1,35 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import 'auth_repository.dart'; + +class CustomersRepository implements Disposable { + CustomersRepository(this._authRepository); + + final AuthRepository _authRepository; + + final loadCount = Signal(0, name: 'loadCount'); + + bool hasSession() { + loadCount.value = loadCount.value + 1; + return _authRepository.session.value != null; + } + + int? sessionLength() => _authRepository.session.value!.length; + + void clearSession() { + _authRepository.session.value = null; + } + + void ensureSession() { + _authRepository.session.value ??= 'anon'; + } + + bool hasSessionViaThis() { + return this._authRepository.session.value != null; + } + + @override + void dispose() { + loadCount.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_no_state/plain_settings.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_no_state/plain_settings.g.dart new file mode 100644 index 0000000..ed53a2a --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_no_state/plain_settings.g.dart @@ -0,0 +1,13 @@ +// Negative-fixture companion for `settings_reader.dart`: `PlainSettings` +// carries no `@Solid*` annotations at all. Reached from +// `settings_reader.dart` only via a constructor-injected field — the new +// constructor-param/field-type seeding introduced for issue #104 still adds +// `PlainSettings` to `wantedTypes` (the seeding is annotation-blind by +// design; it only proposes the receiver's type name as a candidate), but +// the subsequent import walk finds zero `@SolidState` members here, so +// `classRegistry` never gains a `PlainSettings` entry and `_settings.volume` +// must stay byte-identical. + +class PlainSettings { + int volume = 50; +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_no_state/settings_reader.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_no_state/settings_reader.g.dart new file mode 100644 index 0000000..b37c2ee --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_constructor_injected_no_state/settings_reader.g.dart @@ -0,0 +1,21 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import 'plain_settings.dart'; + +class SettingsReader implements Disposable { + SettingsReader(this._settings); + + final PlainSettings _settings; + + final readCount = Signal(0, name: 'readCount'); + + int readVolume() { + readCount.value = readCount.value + 1; + return _settings.volume; + } + + @override + void dispose() { + readCount.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_constructor_param_type_only/analytics_service.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_constructor_param_type_only/analytics_service.g.dart new file mode 100644 index 0000000..dae64fc --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_constructor_param_type_only/analytics_service.g.dart @@ -0,0 +1,11 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class AnalyticsService implements Disposable { + final eventCount = Signal(0, name: 'eventCount'); + + @override + void dispose() { + eventCount.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_constructor_param_type_only/report_builder.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_constructor_param_type_only/report_builder.g.dart new file mode 100644 index 0000000..f73dbd1 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_constructor_param_type_only/report_builder.g.dart @@ -0,0 +1,23 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import 'analytics_service.dart'; + +class ReportBuilder implements Disposable { + ReportBuilder(AnalyticsService service) : _label = service.toString(); + + final String _label; + + final reportsBuilt = Signal(0, name: 'reportsBuilt'); + + int summarize(AnalyticsService service) { + reportsBuilt.value = reportsBuilt.value + 1; + return service.eventCount.value; + } + + String describe() => _label; + + @override + void dispose() { + reportsBuilt.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/main.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/main.g.dart new file mode 100644 index 0000000..993de7a --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/main.g.dart @@ -0,0 +1,22 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import 'right_meter.dart'; +import 'wrong_meter.dart' hide Meter; + +class Display implements Disposable { + Display(this.meter); + + final Meter meter; + + final loadCount = Signal(0, name: 'loadCount'); + + int describe() { + loadCount.value = loadCount.value + 1; + return meter.reading.value; + } + + @override + void dispose() { + loadCount.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/right_meter.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/right_meter.g.dart new file mode 100644 index 0000000..24109e3 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/right_meter.g.dart @@ -0,0 +1,11 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class Meter implements Disposable { + final reading = Signal(0, name: 'reading'); + + @override + void dispose() { + reading.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/wrong_meter.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/wrong_meter.g.dart new file mode 100644 index 0000000..6d383f6 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_import_hide_combinator/wrong_meter.g.dart @@ -0,0 +1,11 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class Meter implements Disposable { + final wrongField = Signal(0, name: 'wrongField'); + + @override + void dispose() { + wrongField.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_local_shadowing_decoy/main.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_local_shadowing_decoy/main.g.dart new file mode 100644 index 0000000..a2b7b06 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_local_shadowing_decoy/main.g.dart @@ -0,0 +1,27 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import 'remote_address.dart'; + +class Address { + Address(this.line1); + + final String line1; +} + +class Shipping implements Disposable { + Shipping(this.address); + + final Address address; + + final loadCount = Signal(0, name: 'loadCount'); + + String describe() { + loadCount.value = loadCount.value + 1; + return address.line1; + } + + @override + void dispose() { + loadCount.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_local_shadowing_decoy/remote_address.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_local_shadowing_decoy/remote_address.g.dart new file mode 100644 index 0000000..0a71014 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_local_shadowing_decoy/remote_address.g.dart @@ -0,0 +1,11 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class Address implements Disposable { + final line1 = Signal(null, name: 'line1'); + + @override + void dispose() { + line1.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/auth_repository.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/auth_repository.g.dart new file mode 100644 index 0000000..b835061 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/auth_repository.g.dart @@ -0,0 +1,11 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class AuthRepository implements Disposable { + final session = Signal(null, name: 'session'); + + @override + void dispose() { + session.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/base_repository.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/base_repository.g.dart new file mode 100644 index 0000000..80cf72e --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/base_repository.g.dart @@ -0,0 +1,12 @@ +// Plain, unannotated base class that owns the `authRepository` field. +// `customers_repository.dart` never redeclares this field — it only +// forwards to it through an explicitly-typed super-initializer parameter — +// so any read of `authRepository` in the subclass is an INHERITED-member +// read. See issue #104 fix review, finding 3. +import 'auth_repository.dart'; + +class BaseRepository { + BaseRepository(this.authRepository); + + final AuthRepository authRepository; +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/customers_repository.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/customers_repository.g.dart new file mode 100644 index 0000000..897dc3f --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_super_param_type_only/customers_repository.g.dart @@ -0,0 +1,20 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import 'auth_repository.dart'; +import 'base_repository.dart'; + +class CustomersRepository extends BaseRepository implements Disposable { + CustomersRepository(AuthRepository super.authRepository); + + final loadCount = Signal(0, name: 'loadCount'); + + bool hasSession() { + loadCount.value = loadCount.value + 1; + return authRepository.session.value != null; + } + + @override + void dispose() { + loadCount.dispose(); + } +} diff --git a/packages/solid_generator/test/integration/golden_helpers.dart b/packages/solid_generator/test/integration/golden_helpers.dart index ac8e12d..1510a1b 100644 --- a/packages/solid_generator/test/integration/golden_helpers.dart +++ b/packages/solid_generator/test/integration/golden_helpers.dart @@ -137,6 +137,13 @@ const List goldenNames = [ 'cross_file_dispose_from_annotated_main', 'cross_file_dispose_unknown_type_injected', 'cross_file_registry_decoy_collision', + 'cross_file_constructor_injected_holder', + 'cross_file_constructor_param_type_only', + 'cross_file_constructor_injected_no_state', + 'cross_file_local_shadowing_decoy', + 'cross_file_import_hide_combinator', + 'cross_file_super_param_type_only', + 'cross_file_collection_generic_arg_seeding', ]; /// Memoized golden directory resolution. Resolved relative to the package From 0b9fd0b169ba1bb40604e25f64c5349ea46dee56 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Tue, 25 Aug 2026 22:30:36 +0700 Subject: [PATCH 2/2] test(solid_generator): pin Computed-from-injected-state cross-file lowering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../auth_repository.dart | 19 ++++++++ .../session_summary.dart | 43 +++++++++++++++++++ .../auth_repository.g.dart | 11 +++++ .../session_summary.g.dart | 28 ++++++++++++ .../test/integration/golden_helpers.dart | 1 + 5 files changed, 102 insertions(+) create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_computed_from_injected_state/auth_repository.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_computed_from_injected_state/session_summary.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_computed_from_injected_state/auth_repository.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_computed_from_injected_state/session_summary.g.dart diff --git a/packages/solid_generator/test/golden/inputs/cross_file_computed_from_injected_state/auth_repository.dart b/packages/solid_generator/test/golden/inputs/cross_file_computed_from_injected_state/auth_repository.dart new file mode 100644 index 0000000..bc51a18 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_computed_from_injected_state/auth_repository.dart @@ -0,0 +1,19 @@ +// Cross-file `@SolidState` host for the constructor-injected-holder + +// Computed-getter combination (issue #104 follow-up). `session_summary.dart` +// reaches this class ONLY through a constructor-injected field — no +// `@SolidEnvironment` field and no same-file `.environment()` / `Provider()` +// call site anywhere in this fixture — so the cross-file registry seeding in +// `builder.dart::_populateCrossFileTypes` must pick up `AuthRepository` from +// the consumer's constructor parameter / instance field declared type to +// recognize it as Solid-lowered, exactly as in +// `cross_file_constructor_injected_holder`. This sibling fixture drives the +// same seeding path but through a `@SolidState` GETTER (Computed lowering) +// instead of a plain user method — see `session_summary.dart` for why that +// matters. + +import 'package:solid_annotations/solid_annotations.dart'; + +class AuthRepository { + @SolidState() + String? session; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_computed_from_injected_state/session_summary.dart b/packages/solid_generator/test/golden/inputs/cross_file_computed_from_injected_state/session_summary.dart new file mode 100644 index 0000000..b66fdd3 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_computed_from_injected_state/session_summary.dart @@ -0,0 +1,43 @@ +// Same constructor-injection DI shape as +// `cross_file_constructor_injected_holder/customers_repository.dart`, but +// the cross-file field is read from inside a `@SolidState` GETTER, which +// lowers to a `Computed` (`emitComputedField` in `signal_emitter.dart`, +// invoked from `plain_class_rewriter.dart`'s getter branch) instead of a +// plain user method. This is the previously-untested combination: the +// cross-file registry seeding fixed for issue #104 threads through +// `readSolidStateGetter` the same way it threads through +// `readSolidEffectMethod` / user-method rewriting, but that path had no +// dedicated coverage. +// +// Danger case if the combination were broken: the Computed closure's +// cross-class read keeps `_authRepository.session != null` un-lowered (no +// `.value`). A `Signal` object is always non-null, so `hasSession` +// would permanently and silently evaluate to `true` regardless of the +// actual session state — a stale/wrong derived value with no error at +// build or run time. +// +// `sessionLength` mirrors the sibling fixture's `!.`-chain method +// (`cross_file_constructor_injected_holder/customers_repository.dart`'s +// `sessionLength()`) but as a second Computed getter, confirming the +// null-assert chain shape is also legal through the Computed lowering path +// (`cross_class_instance_field_read.dart` already proves the `!.` shape +// legal for plain cross-class methods; this proves it for getters too). + +import 'package:solid_annotations/solid_annotations.dart'; + +import 'auth_repository.dart'; + +class SessionSummary { + SessionSummary(this._authRepository); + + final AuthRepository _authRepository; + + @SolidState() + int loadCount = 0; + + @SolidState() + bool get hasSession => _authRepository.session != null; + + @SolidState() + int get sessionLength => _authRepository.session!.length; +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_computed_from_injected_state/auth_repository.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_computed_from_injected_state/auth_repository.g.dart new file mode 100644 index 0000000..b835061 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_computed_from_injected_state/auth_repository.g.dart @@ -0,0 +1,11 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class AuthRepository implements Disposable { + final session = Signal(null, name: 'session'); + + @override + void dispose() { + session.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_computed_from_injected_state/session_summary.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_computed_from_injected_state/session_summary.g.dart new file mode 100644 index 0000000..45e0164 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_computed_from_injected_state/session_summary.g.dart @@ -0,0 +1,28 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import 'auth_repository.dart'; + +class SessionSummary implements Disposable { + SessionSummary(this._authRepository); + + final AuthRepository _authRepository; + + final loadCount = Signal(0, name: 'loadCount'); + + late final hasSession = Computed( + () => _authRepository.session.value != null, + name: 'hasSession', + ); + + late final sessionLength = Computed( + () => _authRepository.session.value!.length, + name: 'sessionLength', + ); + + @override + void dispose() { + sessionLength.dispose(); + hasSession.dispose(); + loadCount.dispose(); + } +} diff --git a/packages/solid_generator/test/integration/golden_helpers.dart b/packages/solid_generator/test/integration/golden_helpers.dart index 1510a1b..6194f5f 100644 --- a/packages/solid_generator/test/integration/golden_helpers.dart +++ b/packages/solid_generator/test/integration/golden_helpers.dart @@ -144,6 +144,7 @@ const List goldenNames = [ 'cross_file_import_hide_combinator', 'cross_file_super_param_type_only', 'cross_file_collection_generic_arg_seeding', + 'cross_file_computed_from_injected_state', ]; /// Memoized golden directory resolution. Resolved relative to the package