spm 0.7.1
spm: ^0.7.1 copied to clipboard
Unified static analysis and runtime profiling for Flutter rebuild scopes and their triggers.
Changelog #
0.7.1 #
A field, a check and a crash on top of 0.7.0, and then four defects and a round of consolidation. The emitted Dart is byte-identical to 0.7.0's for every scope, so isolate output from the two releases can be pooled.
Three of the four defects are in code no test reaches. injection and profiler have no test files
at all, and lib/spm.dart, the library instrumented apps import, is never imported by one. That is
how a print in a public API survived to a release candidate.
Added #
-
inlinedThirdPartyPackageson everyisolatemapping row: the hosted packages the transplant carried source from, name to version, omitted when there are none.inlinedThirdPartyDeclarationssaid how much package code a scope carried and nothing said whose it was, so a consumer deciding whether the isolated file may be redistributed had no way to ask.carriedUiDeclarationsdoes not answer it either. It names the project's own libraries in the same list, and it records a declaration before the budget decides whether to keep it.Written inside
InlineBudget.take, in the same statement that increments the count, so the two cannot drift: a row reporting a count and no packages means a path or git dependency, whose directory carries no version to read, and never a recording somebody forgot to make.takenow requires the path the declaration's source was read from, which is what makes that structural rather than a convention.Removed from the row alongside the count when
isolatereverts unprofitable inlining, since the package source is no longer in the file it would be describing.
Changed #
-
isolaterejects a directory that does not exist, the wayanalyzealready did. It used to take the path, find nothing under it and write an empty output directory, so a typo in a project path looked like a project with no rebuild scopes in it. Both commands now read their positional arguments through one mixin,DirectoryArguments, and print the same usage error. -
The predicate for "is this type a
Widget" lives incore/analysis/type_predicates.dartand both commands ask it.analyzeandisolateeach carried a private copy, and the two bodies were identical character for character. Two copies of the predicate that separates a widget from a value object is two chances for the commands to disagree about what they are counting, and a row from each is meant to describe the same tree.hasSupertypeNamedmoved there with it, since the emitter's own version answered the same question one step earlier. -
The three isolation callers that ask whether a path belongs to the project being analysed share
helpers/project_paths.dart. Each carried the same two comparisons; a disagreement between any two of them would mean a declaration inlined by one gate and stood in for by another. -
Every DI class now exposes
reset().ProfilerDIwas the one that did not, while the documentation said all of them did.
Fixed #
-
SpmState.setStateno longer prints on every rebuild. The line ran before the mode branch, so it fired in release builds as well as profile and debug, once persetStateper instrumented widget, in every app that depends on this package. It was the onlyprintinlib/, which the repository guidance already forbids in favour ofSpmLogger. -
spm runcould throw out of a stream callback while the app was starting. The VM service URI is awaited on one completer and both process streams were scanned for it, but only the stderr listener asked the completer whether it was already done; the stdout one tracked the same thing in a local flag of its own. Flutter prints that line to either stream depending on the device and the command, so a URI seen on stderr first left the stdout flag false, and the next matching line completed a completed completer. One scanner now serves both streams and asks the completer. -
monitorDataFlowcounts the expensive widgets thatAppConstants.expensiveWidgetsnames, rather than the six aswitchbeside it happened to repeat. The guard asked the constant and the body then re-enumerated the same names, so the constant looked like the single list and was not: a widget added to it passed the guard and incremented nothing. The tally is now keyed by type name, so the set is the only place the list is written. The event still carries the same six named fields, so a seventh name is counted but has nowhere to be reported until the entity gains a slot for it. -
The transplant recognises
Obx,GetX,GetBuilderandObserverwhen it walks out of a builder callback looking for the widget that owns it. It matched against a five-name literal standing besideAppConstants.builderScopeWidgets, which holds nine, so for a GetX or MobX scope the walk ran past the builder to whatever enclosed it and inferred the callback parameter's type from the wrong widget.GetX<C>andGetBuilder<C>now read their controller type the wayConsumer<T>already did.ObxandObserverstay out of that second step on purpose, since neither carries a type argument the callback binds to. -
spm isolatereads the scope-type label fromAppConstants.stateScopeTypewhere it used to compare against a'State'literal. The visitor that produces the label already read it from the constant, so producer and consumer are no longer two independent spellings of one string.
Removed #
ComplexityExtractionException,ContextExtractionExceptionand the two failures they mapped to. Neither exception was thrown anywhere in the package. They were only caught, inanalyze's repository, and those two unreachable arms were the only place the two failures were ever constructed.failures.dartnow declares 17 subclasses ofFailure,CompoundFailureamong them.
0.7.0 #
Everything since 0.6.0. Two things carry the release. spm analyze reads a library outside the
analysed directories instead of dropping it and its whole subtree. And both commands stop counting
the code a rebuild cannot run, which is what the body of an onPressed is.
Every number isolate reported used to stop at errorCount == 0, a fact about the analyzer rather
than about the output, and three decisions in the emitters guaranteed that a clean-analysing file
threw before its first frame. Most of what follows is what it takes to make the written file run
rather than merely analyse.
Reading a package library closes the asymmetry the 0.6.0 notes had to state out loud: a transplant that carried a package widget counted its subtree while the in-place row for the same scope did not, so the two sides of the comparison were not comparable in either direction.
Output cannot be pooled with 0.6.0's on either side. The emitter produces a different file for the
same scope, and TreeExtractor produces different metrics for the same code. That second half is
new: until this release only the isolate output carried a pooling warning.
Added #
--[no-]prune-non-rebuildonisolate, on by default.--no-prune-non-rebuildreproduces the unpruned output, so a run made with and without it is a controlled comparison rather than an assertion. Across 179 scopes, pruning takes the files that analyse clean from 25 to 46 and the total error count from 2,793 to 1,253, and the 14 feature values are unchanged wherever both settings produce a row: 32 of 32 shared scopes byte-identical, which is the invariant the prune is allowed to be judged on. Rows go up, 45 to 62, because the files that now analyse contribute their own scope. The rows that disappear are duplicate observations of a nested scope that already has a transplant of its own.--package-configonanalyze. A package version is an input to the metrics now, so two runs over one project whosepubspec.lockmoved between them could differ with no source edit to explain it. Pinning one resolved config removes that by construction. The cost is that a subtree may be counted against a package version the checkout did not ship with, which is the right trade when the question is about the project's own structure.--inline-max-declarationsand--inline-max-charactersonisolate, with the caps raised from 200 and 200,000 to 2,000 and 2,000,000. The in-place walk has no cap at all now, so a scope that exhausts this one undercounts against the row it is meant to be compared with. The counter stays, as a number to report rather than a limit to aim at.GeneratedWidget.fixture(), emitted beside the copied constructor. A scope whose widget declared a required field could not be written asGeneratedWidget(), and mounting it meant building that value first, which is a second fix point outside the fixture block and different for every scope. The copied constructor stays, because it is part of the commit's source. A row says which it got throughfixtureConstructor.- Seven mapping fields on
isolate.unseededBindingsnames the bindings no value could be built for;fixtureConstructorsays whether the fixture constructor was emitted;droppedLoadingBuilderscounts the one image argument that still cannot come across;carriedUiDeclarationsandrenamedThirdPartyDeclarationsrecord what was carried and under what name;erasedNonRebuildBodiescounts the closure bodies emptied; anddroppedUnreachableMembersnames the members left out. That last one is named rather than counted, because a member missing that should not be is the failure mode of the prune and a count cannot say which one. The run also reports how many scopes gave their third-party source back. - Two analyze fields.
packageVersionsrecords the resolved version of every package the closure entered, so the pin above is auditable, andwalkedWidgetClassesrecords the non-SDK classes walked in place. The second is the other half of a check that could not be computed before: for every declarationanalyzewalks,isolatehas to carry the source, or the two rows describe different trees.
Changed #
- A closure a rebuild cannot run is no longer entered by
analyze. Its widgets, allocations, iterations, helper references and decision points stay out of the row, and a custom widget built inside it no longer seeds the child traversal. Three shapes qualify: a named argument whose label isonfollowed by a capital, a named argument calledvalidator,onError,onDone,onCancelorconfirmDismiss, and an argument tothen,catchError,whenComplete,addListener,addPostFrameCallback,scheduleMicrotask,Future.delayedorTimer. A tear-off in one of those slots is treated the same way, and a local function reachable only from one is dropped rather than read at the end of the traversal. Every feature is smaller than 0.6.0 reported it for any scope holding a handler, and the reason is that a handler body is not rebuild cost: anonPressedthat pushes a route used to merge a whole other screen's build tree into the scope, charging a rebuild for a page it never renders. - Everything else is still entered:
builder,itemBuilder,separatorBuilder, the builder callbacks of the supported scope widgets, positional ones included, and any expression in a handler slot that is not itself a closure, since a conditional tear-off is evaluated while the tree is built. analyzereads a library outside the analysed directories.contextForthrowsStateErrorfor any path in the pub cache, the throw was swallowed, the entry cached as a miss, and the child dropped along with its whole subtree. A per-package-root context collection built against the application's own package config fixes it. Nothing walks the cache: only the libraries the traversal actually enters are resolved. The framework boundary is unchanged and stays a correctness decision rather than a cost one, because the visitor counts every branch of a build body rather than the branch that ran.- A library that resolves with errors is now refused, as one that does not resolve at all already
was. Its types come back null, so its widgets classify as value objects: the row was wrong rather
than short, which is the trade the scanned-file gate exists to prevent. Repo-local numbers move
because of this, and
unresolvedDependenciesnames what was refused. - Carrying a package widget is now what makes the isolated row and the in-place row describe the
same tree, where up to 0.6.0 it did the opposite. Where they still cannot, the row says so:
thirdPartyInlineTruncated,thirdPartyInlineReverted, andcarriedUiDeclarationsagainst the analyze row'swalkedWidgetClasses. - Reverting an unprofitable inline is a defect count rather than an achievement. It buys a readable file by shrinking the tree, which is the wrong direction when the isolated row and the in-place row are meant to describe the same thing. The rate is reported, and such a row is one to exclude rather than to compare.
- A third-party declaration whose name
package:flutter/material.dartalso exports is carried under a mangled name rather than stood in for, and only the references that resolve to it are rewritten. The guard's reasoning is unchanged: an inlined third-partyCardwould put a body under everyCard(...)in the transplanted body, including the ones that meant Flutter's. What changes is that honouring it no longer costs the subtree. - A widget stand-in renders its
child,childrenorbodyinstead of swallowing it. The constructor accepted the argument and the class builtconst SizedBox.shrink(), so whatever tree was passed in was constructed and then never mounted, laid out or painted.childrenwraps inStackand notColumn:Columnwith a non-literalchildren:pinstreeListRenderingStrategyat its ceiling for every scope reaching such a stand-in, in the transplant and nowhere else. - Stand-in parameter types are
dynamic. Rendering a type by its nearest nameable supertype while the argument at the call site was a stand-in with none is what produced most of the output's error-severity diagnostics. Return and field types degrade too, unless the type reachesWidgetdirectly or as an iterable's element, which is the property the widget-returning-helper rule depends on. - A non-widget stand-in declares its nearest nameable supertype, which carries generic bounds a
dynamicparameter cannot. Members the supertype supplies are not redeclared beside it. - A stand-in emits every constructor, whatever the member limit says. The two were gated on the same flag, so a type over the limit emitted none unless a reference reached one, and a call site's named arguments then landed on the implicit default constructor.
- Bindings carry values instead of throwing. Unassigned
lateseeds,throw UnimplementedError()bodies and unassignedlate dynamicglobals each threw on first read, and the reads are ininitStateandbuild. Adynamicbinding gets a_Stubrather thannull, because the bodies read member chains off these and a null only moves the crash frominitStateintobuild. This reverses the emitters' stated preference for throwing over inventing, and the reason it is safe is that a value is never measured: the features come from the shape of the build tree, and that shape is fixed before any of this executes. Where no value of a binding's type can be built the old form stays and the name is reported. - The image rewrite substitutes the source and keeps the node. Replacing the whole construction
erased
errorBuildersubtrees, which in place are walked and counted, and turned anImageProviderinto a widget, moving the same source out ofvalueObjectAllocCountintotreeNonConstWidgetCountand adding a level of depth. It also put a widget in a provider-typed slot, whichBoxDecoration(image:)andCircleAvatar(backgroundImage:)reject.loadingBuilderhas no home onImage.assetand is the one argument that still drops; it is counted and the file carries a marker where it stood. - A closure
isolatetransplants into a non-rebuild slot keeps its signature, itsasyncmodifier and its place in the tree, and loses its body. The body becomes an empty block where the closure returns nothing and a throw where it returns a value:{}completes with null, which aString? validatortolerates and aFuture<bool> confirmDismissdoes not. The distinction is not pedantry. The same family coversthen,addListener,addPostFrameCallback,scheduleMicrotask,TimerandFuture.delayed, and those bodies do run, moments after the scope mounts, so throwing in one would trade an analyzer error for an uncaught exception around the first frame. - A member of the scope's class that
build()cannot reach is dropped. A member only a tear-off names keeps its signature and loses its body: the reference is evaluated while the tree is built, so the name has to resolve. Carrying those members is what used to pull whole navigation targets into the file and stand in for the services they called, and a stand-in whose signature then failed to type-check cost the file outright, becauseanalyzeskips any file carrying an error-severity diagnostic. initStateanddidChangeDependenciesare dropped like any other memberbuildcannot reach, and what they seeded moves to the fixture block. Alate finalfield the originalinitStateassigned is now assigned from a top-level fixture symbol declared at the bottom of the file beside the bindings lifted from the application. The generatedinitStatedoes the seeding and nothing else, so the network calls, listener registrations and notification setup that used to sit beside it, and every stand-in they dragged in, are gone. Keeping those two methods was the first answer and it was the wrong one: what they contribute is a value, and supplying a value is something the transplant already knew how to do.- Every dependency is now a
latebinding seeded from the block, not only the ones a dropped member used to assign. A field the scope declared with its own value,int _limit = 20;, becomeslate int _limit;in the State,_limit = fixtureLimit;in the generatedinitState, andint fixtureLimit = 20;at the top level of the file. The value is relocated, never replaced: a list seeded with twenty rows still builds twenty. The fixture block is therefore the whole of a scope's initial state, in one region, at the top level of the file, which is what lets two revisions of one scope be mounted from identical values. finalis dropped from a hoisted binding. A member the prune keeps may still assign the field, andlate finalwould make that a second write to a final.- Three kinds of field stay where they are, and every reason is about not moving a feature.
staticandconstfields, becausetreeConstWidgetCountandrootBuildReturnsConstWidgetare features and astatic constread inside aconstconstructor stops the call being const the moment it becomes a variable. A field whose initialiser needs the instance, such as one readingwidget.arguments, because it cannot be evaluated at the top level. And a field with no nameable type, because the fixture declaration has to write the type down. - A generated default no longer names a type argument the file does not declare. A typed empty list against a type nothing carried is an error-severity diagnostic, and the untyped literal infers the same thing from the context every one of these sits in. This applies with the prune off too.
- The declared type of a lifted binding is requested from the crawl outright. It is rendered from the element model rather than from the copied source, so the crawl used to reach it only by accident, through some other mention, and a handler body was often the only one.
- Overlapping source edits resolve outermost-first rather than corrupting each other. Edits now come from several independent rewriters, and a nested one could not survive the right-to-left pass.
- The rebuild-path predicate is one helper in
core, shared byanalyzeandisolate, so the two commands cannot drift on which callbacks a rebuild runs.
Fixed #
- A prefix from a package the isolated file may not import no longer dangles. An SDK prefix such as
dart:math as mathwas restored and a package one was not, leaving the prefix undefined. It becomes adynamicstand-in, so a call through it compiles with no import at all. Restoring the directive instead would beuri_does_not_exist, which is error severity too. - A stand-in no longer writes a parameter named after another library's private field.
- The extractor's per-package-root context collections are released at the end of a run. Each one runs a driver scheduler, and left alive they accumulate across runs in the same process, which a single CLI invocation never notices and a test suite does.
0.6.0 #
Everything since 0.5.2. Two changes carry the release, and both are about isolate writing a file
that describes the code it came from. A transplanted StatefulWidget now brings its State's
dependencies with it, and a third-party widget now arrives with its own tree instead of an empty
stand-in. Alongside them, several things that reported success without having earned it now say so
instead.
Output from 0.6.0 cannot be pooled with 0.5.2's: the same scope produces a different file and, where a package widget is involved, different metrics.
Added #
--inline-third-party, on by default.--no-inline-third-partystands every third-party symbol in, which is whatisolatedid up to 0.5.2.- Three mapping JSONL fields, all omitted unless they apply:
inlinedThirdPartyDeclarationscounts the third-party declarations carried into a file;thirdPartyInlineTruncatedmarks a scope that reached the per-scope budget;thirdPartyInlineRevertedmarks a scope where carrying the code analysed worse than standing it in, so the stood-in version was kept. The last two both say the file describes a smaller tree than the code it came from builds. helpers/ui_surface.dartholds the predicate that decides whether a declaration can produce UI, in both an AST form and an element-model form, so the transplant's inline gate and the dependency visitor's gate cannot drift apart.helpers/inline_budget.dartandhelpers/flutter_namespace.darthold the two limits described below.
Changed #
- The dependency gate now asks "is this the SDK" rather than "is this project-local". The SDK is
imported, anything that can produce UI is carried as source whether it is repo-local or
third-party, and everything else becomes a declaration-only stand-in. A third-party
StatefulWidgetarrives with its companionState, which is the half that matters, since that is where the build body lives. A stood-in widget has an emptybuild, so a file full of them describes a tree the app never built and cannot be read or run as the scope it came from. - Carrying third-party source is bounded, unlike the repo-local kind, at 200 declarations or 200,000 characters per scope. A repo-local closure is bounded by the repository already; a third-party one is not, and a scope holding a single state-management builder reaches a widget from which the crawl walks into the package's own machinery.
- Carrying is undone per scope when it does not pay. After the output is verified, any scope that
carried third-party source and still does not analyse is extracted a second time with that source
stood in for, and whichever version has fewer errors is kept. A package widget generic over a type
bounded by one of the package's own classes is the shape that needs this: carrying the widget
brings its real bound along, and the repo-local class that satisfies that bound in the application
is a stand-in here with no supertype at all, so a file that type-checked against a stand-in's
dynamicstops type-checking. Rather than keep a list of packages that behave this way, both answers are analysed and the better one wins, which makes the guarantee exact: no scope ends up with more errors than--no-inline-third-partywould have given it. - A third-party declaration whose name
package:flutter/material.dartalso exports is stood in for rather than carried. A local declaration shadows the import either way, but an empty stand-in namedCardonly costs the subtree under eachCard(...), where a carried one puts a body under every use of the name, including the uses that meant Flutter's. - The same-file rule, "within a file take everything", was written about project files and now applies to package units as well, so it takes the budget and the material-name check with it. Without that, a package's own declarations entered through a door the third-party gate does not watch.
SvgPictureandCachedNetworkImageare no longer in the set of image constructions rewritten toImage.asset('assets/placeholder.png'). They are widgets from packages, and substituting one widget for another was hiding whatever those packages build. The rewrite now requires an SDK-owned element, so Flutter's ownImage,AssetImage,NetworkImage,FileImage,MemoryImage,DecorationImage,FadeInImageandRawImagestill take the placeholder, because an isolated file has no assets directory and no network.- Resolving a dependency's unit is guarded. A third-party reference points at a file outside the project rather than inside it, and an unreadable one now costs a stand-in instead of the whole scope.
- Unused imports are no longer pruned from the output. The pruner was line based, and
dart formatwraps a longshowclause across lines, so pruning one could leave the rest of the clause behind and turn a warning into a parse error. Removing it also removes the re-analysis round trip that was the most likely way to reach the verifier's swallowed-diagnostics bug listed under Fixed. An unused import is a warning, never an error, so it does not stopanalyzefrom reading the file; prune it downstream over an AST if the output needs to be clean of them. - The mapping JSONL ends every line,
analyze's output always did. - Passing the same directory twice no longer isolates every scope in it twice. The per-input directory filter it replaces could not admit a context twice for distinct inputs anyway: the analyzer roots each context at an included path and merges overlapping ones.
- The walk up to the nearest
.dart_tool/package_config.jsonis now one helper,helpers/package_config.dart, shared by the extractor and the verifier instead of living only in the verifier. - The isolation tests share one transplant run per file rather than repeating it for every test.
Fixed #
- The companion
Stateof an inlined StatefulWidget was copied and never visited, so nothing it referenced reached the dependency crawl: no stand-in, no import, no cross-file reference. AStatebody is where a StatefulWidget keeps everything it depends on, which is what made this expensive: a widget whose data types are named only inside itsStatehad the code that names them carried across and a declaration for none of them. The companion is visited under its own class rather than the widget's, so a reference to one of its own methods reads as a member of the class that declares it. - A widget stand-in carried
createStateanddebugFillProperties, both of which only the framework calls and neither of which a stand-in can honour.createStatereturnsState<T>, whose bound isStatefulWidget, against a stand-in deliberately collapsed toStatelessWidget, so standing in for a stateful widget produced a bound violation on the stand-in's own signature.debugFillPropertiesnamesDiagnosticPropertiesBuilder, whichpackage:flutter/material.dartdoes not export, sincewidgets.dartre-exports foundation asshow Brightness, UniqueKey. - Deciding whether an import already provides a name walked the export graph, which ignores
showandhide. The analyzer says as much in its own doc comment onexportedLibraries, and Flutter is built out of those clauses:widgets.dartre-exports foundation asshow Brightness, UniqueKey, so every foundation symbol reached from a file importing onlymaterial.dartmatched material, and the fallback that would have written the real import never ran. The question is now asked of the export namespace, which is the one that honours the clauses.DiagnosticPropertiesBuilder,Diagnosticable,kDebugModeandcomputeare all this shape. - A builder given a tear-off rather than an inline closure was a scope to
isolateand not toanalyze. There is no callback body at the creation site, so the transplant fell through to its expression fallback and returned the function itself where aWidgetbelongs: a file that can never analyse clean, a row in the mapping, and a count in the summary, for a scopeanalyzenever reports.findBuilderArgumentnow returns only aFunctionExpression, so both commands take the rule from one place. - The verifier reported a file it could not analyse as a file with no errors. Every failure to
fetch diagnostics was swallowed and became an empty diagnostic list, which is indistinguishable
from a clean run. It now reports
verified: false, which is what the unverified and clean split existed to express. sourceDependenciesResolvedcould only ever be false once per checkout. An existingpackage_config.jsonwas taken as proof that resolution had happened, and the minimal configisolatewrites whenpub getfails satisfies that check, so the flag fired on the run that created the file and never again. Walking a repository's history, where a worktree keeps its.dart_toolacross checkouts, that is every revision after the first. A configisolatewrote itself now counts as unresolved, and a directory with no pubspec and no config above it does too. Counts of this flag taken from output written by 0.5.2 or earlier are floors.isolateaccepted a directory that does not exist and reported success over zero scopes. A missing path resolves to a context rooted at the nearest real package above it, whose files are then all filtered out, so a typo read exactly like a project with no rebuild scopes in it. An input that produces no analysis context at all is now an error too.- The set of projects whose dependencies failed to resolve was never cleared between calls. The
data source is a lazy singleton, so a second
isolate()in the same process still carried the first one's verdict and markedsourceDependenciesResolved: falseon rows from a project that resolved perfectly well. Only the CLI, which runs one isolation per process, was unaffected.
Notes #
- Carrying a package widget does not make an isolated row match the in-place row, which is the
obvious guess and the wrong one.
BuildMetricsVisitordoes record a non-SDK widget as a custom child, butTreeExtractorthen asksAnalysisContextCollection.contextForfor its file, and that throws for any path outside the analyzed roots, which is where a package's source sits. The child is dropped and its subtree with it, soanalyzenever counted a package widget's tree in place either. A row that carried one therefore counts more than the same scope does in place, not less. Isolated and in-place numbers are not comparable across that boundary in either direction.
0.5.2 #
isolate now analyses what it wrote before it reports success, so every run says how many of its
files a later spm analyze can actually read. The fixes below all change what isolate writes.
Files produced by 0.5.1 and earlier carry imports of packages that were never meant to be there and
references to names nothing declares, so they cannot be pooled with 0.5.2 output.
Fixed #
- The gate that decides which libraries an isolated file may import tested
package:flutterwithout the trailing slash, so every pub package whose name begins withflutterpassed as an SDK library. Each one was imported back into the isolated file instead of being stood in for, leaving output that only resolves inside the project it came from. Sizing extensions such as.spand.wwere the visible half of this, since the import that was supposed to define them does not exist where the file is read. - Import prefixes were dropped. A scope whose source read
import 'dart:math' as math;was written out with a plainimport 'dart:math';, so everymath.piandmath.Random()in the transplanted body became an undefined name. Prefixes,showclauses andhideclauses now travel with the import, including prefixes from the other files a transplant copied code from. Adeferredimport is deliberately not copied: the generatedbuildnever callsloadLibrary(). - The branch that matched a reference back to the import directive it came through read the directive's element under two names the current analyzer does not expose, so it threw and was skipped for every import. Every import fell to a fallback that rebuilds the directive from the library's URI alone, which is where the prefixes and combinators were being lost.
- A declaration written in a
partfile was reported against the file that defines the library, so the same-file lookup searched a unit that does not declare it, found nothing, and marked the name handled on the way out. Private widgets declared in a part were left undefined, which does not merely fail to compile:analyzeskips the subtree of a child widget it cannot reach, so the row is wrong rather than absent. - A same-file lookup that found nothing, and a cross-file reference whose file did not resolve, both used to leave the name dangling. Each now falls back to a declaration-only stand-in.
- A stand-in carried only the members the crawl happened to reach, so a controller could arrive with
removeListenerand withoutaddListener. Members are now recorded against the type the code names rather than the type that declares them, which is what was losing every member inherited from a Flutter base class such asChangeNotifier, and a type that declares 40 members or fewer comes across whole.
Added #
- References the analyzer resolves to nothing now get stand-ins rebuilt from the call sites. Two
situations produce them: an extension defined in a package the isolated file may not import, which
is what
context.read<T>(),context.watch<T>()andcontext.select<T, R>()are, and a source project whose ownpub getnever succeeded, where no third-party name resolves at all. An unresolved constructor call in a widget position is stood in for by a widget, so the allocation is still counted as one. isolateanalyses the files it wrote, in the same process, before reporting. Imports nothing uses are removed, and each mapping row gainsverified,errorCount,warningCount,topCodes,unresolvedImportsandunresolvedNames. The run prints how many files analyse clean, which is the number that decides how much of the outputanalyzecan read.- A row carries
sourceDependenciesResolved: falsewhen the project it came from had no resolvable dependencies, so a consumer can exclude or re-run those rows instead of treating their metrics as comparable. The condition is logged as an error when it happens rather than passing silently. - The output directory gets a
pubspec.yamland a.dart_tool/package_config.jsonborrowed from the source project, so the isolated files resolvepackage:flutterwhere they now sit.
0.5.1 #
Fixed #
isolateread an extension type's name throughExtensionTypeDeclaration.primaryConstructor, which analyzer 14 deprecates in favour ofnamePart. Since the package supports analyzer 13 as well, wherenamePartdoes not exist on that node, the name now comes from the declaration'sClassNamePartchild, which both versions expose. Behaviour is unchanged; the deprecation warning that cost points on the pub.flutter-io.cn static analysis report is gone.
0.5.0 #
Every fix below changes what isolate writes, and the first one changes the metrics analyze
reads back out of it, so results from 0.4.0 and 0.5.0 cannot be compared or pooled.
Fixed #
isolatediscarded part of what its own dependency crawl resolved. The cross-file loop recursed into each inlined declaration with a new visitor and then read only that visitor's list of further cross-file references, dropping every declaration it had resolved inside the file it was already reading. The base class of an inlined widget is the case that mattered: a widget is inlined precisely because its resolved supertype chain reachesWidget, so emitting the subclass without its base left the chain broken. That is not only a compile error.analyzedecides between a widget and a value object by walking that chain, so the allocation moved intovalueObjectAllocCountand its whole build subtree went missing from the metrics.- A rebuild scope's own constructor was copied verbatim into the generated
_GeneratedWidgetState, where its name no longer matches the enclosing class and Dart reads it as a bodiless method. The constructor is now dropped, and fields it used to initialise are markedlateso dropping it does not leave them unassigned. Both field formal parameters and initialiser lists are recognised. Consumer and builder scopes were hit hardest, because converting one into aStateharness carried its widget constructor across. - Default values written with the pre-Dart-3 separator,
{int flex: 2}and[double size: 8], are rewritten to use=. Repository code old enough to use the colon form used to be copied verbatim into a file that a modern SDK then refuses to parse. - The symbols the generated
initStateassigns from are now declared in the isolated file. A lifted field was seeded fromfixtureWalletsand a captured global fromfooValue, but nothing declared either name, so the file carried an undefined-name error andanalyzeskipped it. Each is declaredlateand left unassigned on purpose: a fabricated default would be measured as though it were the value that was really there. - Members reached through an extension, such as
10.sporcontext.h, were dropped by the dependency crawl, which matched only members enclosed by a class. Extensions are now matched too.
Changed #
isolateno longer drops the dependencies it does not inline. A declaration that can build UI is inlined whole, which now includes a class that is not a widget itself but declares a member returning one, sinceanalyzewalks the body of every widget-returning helper a scope calls. Everything else, including third-party symbols that were previously excluded outright, gets a declaration-only stand-in: the name, the members the scope actually reaches, and nothing else. Bodies throw and constants arenull.- A stand-in mirrors whether the original was a widget, so a third-party widget still classifies as
a widget and a value object still classifies as a value object. It cannot reproduce that widget's
own
buildbody, so an isolated scope that instantiates a third-party widget reports a smaller tree than the same scope measured inside its original project. - The isolated file's layout is unchanged. Stand-ins and seeds are appended to the same file rather than written to a separate dependencies file, so output paths and the mapping JSONL are the same as before.
0.4.0 #
Added #
Every analyze row now reports the files its metrics were computed from, and whether all of them
could be read.
- Three columns appended after the 14 metrics, so column order for existing consumers is unchanged:
dependencyFiles,unresolvedDependencies, andclosureResolved(1/0). Paths are relative to the analyzed project root and sorted; closure entries outside that root, such as the SDK and the pub cache, are dropped, since neither is editable by a commit in the analyzed repository. dependencyFileslists the transitive closure a row actually depends on, the declaring file included. A scope's metrics are not a function offilePath: helper methods and getters resolve across libraries, and every custom child widget'sbuild()is merged into the totals. Selecting revisions by "touched the declaring file" therefore drops real changes, and drops them hardest in well-composed code, where child trees are deepest.unresolvedDependencieslists closure libraries that could not be read, by path where one is known and by library URI otherwise. A non-empty list means the row is incomplete by an unknown amount rather than absent, so it can be rejected downstream.
Fixed #
- A closure library that resolves while carrying an error-severity diagnostic is now recorded as unresolved. Such a library resolves its types to null, so its widgets classify as value objects and its subtree lands in the wrong metrics. The scanned/skipped counts in the run summary never caught this: they guard only the file being scanned, not the files its metrics are read from. The index is still built, so the numbers this release emits are unchanged; what changes is that the row now says the numbers are untrustworthy.
- The library cache records its verdict alongside the index, and every lookup is attributed to the scope that made it. The cache lives for a whole run, so a second scope reaching a broken library through a cache hit used to be recorded as clean, and a shared dependency appeared only on the first row that touched it.
isolatelifts the bindings a rebuild scope closed over. A builder callback reads parameters and locals of the method it sits in, and a scope on a package-supplied base class reads members it inherits; neither travels with the transplanted source, so the isolated file referenced names nothing declared.- Lifting a promoted parameter to a field costs it its promotion, because Dart does not promote
fields. References whose promoted type was a proper subtype of the declared type are now wrapped,
so
state.walletsbecomes(state as WalletLoaded).walletsand the isolated file still compiles. - A field named
contextis no longer copied onto the generatedState, where it shadowedState.contextand broke the output. - Stripping nullability from a lifted field's type touched the whole type string, rewriting
(Wallet?, Wallet?)to(Wallet, Wallet)andMap<String, int?>toMap<String, int>. Only the trailing?is dropped now. monitorDataFlowandmonitorPerformancereturned their completer's future from inside atry, whichlints_coreflags and which never routed a rejection through thatcatchanyway. The return moved after the block; the guarded statements and the error path are unchanged.
Changed #
isolategenerates aninitStatethat seeds every lifted field from a conventionally named symbol: fieldwalletsis assignedfixtureWallets, and a cross-file project globalfoois assignedfooValue. The names a scope needs are predictable instead of being rediscovered per scope. A scope that brought its owninitStatekeeps it.isolaterunsdart formatover its output directory. The transplant concatenates fragments that keep their original indentation, so two runs used to differ in layout as well as in code. Formatting failures are ignored: an unparseable scope is still written out for inspection.TreeExtractor.extractreturns anExtractionSet<TreeFeaturesSet>record, pairing the feature set with its closure. This type is internal tolib/src/; the public API is unchanged.
0.3.0 #
Fixed #
Six build-tree metric defects, found by checking extracted values against what the analyzed source actually does. Every one of them changes numbers that 0.2.0 emitted, so metrics from the two versions cannot be compared or pooled.
- Helpers returning a collection of widgets were skipped.
List<Widget> _buildRows()andList<DropdownMenuItem<T>> _buildItems()are widget factories, but the return type had to be aWidgetsubtype for the reference to count, andListis not one, so the reference went uncounted and the body was never read. SDK collection methods such astoListandcaststay excluded: their type saysList<Widget>but they build nothing. - A
constswap inside a helper body moved no metric. Helper const widgets were added tohelperWidgetCountalongside non-const ones, which erased the distinction. Const widgets in a helper now count towardtreeConstWidgetCount, andhelperWidgetCountcovers non-const helper widgets only, matching how build bodies were already split. - List widgets other than
ListViewandGridViewwere left unclassified.ReorderableListView,PageView, andListWheelScrollVieware now classified by constructor, andAnimatedList,AnimatedGrid, and the remaining sliver lists are treated as lazy by contract. Their lazy builders also mark the widgets they build as per-element cost. - Sliver laziness ignored the delegate.
SliverList(delegate: SliverChildListDelegate([...]))builds every child up front and is now eager (2). A builder delegate stays lazy (1). List.generateread as a single allocation. It is a factory constructor, so thegeneratecase in the method-invocation path never saw it. It now counts as iteration, and the widgets its callback builds count as per-element cost.- Local functions lost their per-element attribution. A local function declared above a loop and invoked inside it was read at its declaration site, outside any iteration scope, so a row built per element looked like a one-off. Bodies are now read at the first call site. A local function that is never referenced is still read once, at the end of the traversal.
Changed #
rootBuildReturnsConstWidgetnow requires every top-level return to be const. A single const return used to set it, so a build that returns a full tree on its common path andconst SizedBox.shrink()from a loading guard was recorded as a const build.
0.2.0 #
Changed #
- Documented the planned 1.0.0 static screening direction: classify UI changes as stable or faster
(
0) or slower (1) from build-tree metrics without running or profiling the app.
Removed #
- Breaking: Removed the legacy
package:spm/features/profiler/presentation/compatibility exports. ImportSpmStateandSpmProfilerfrompackage:spm/spm.dart.
0.1.2 #
0.1.1 #
Changed #
- Declared Android as the only supported platform, so the pub.flutter-io.cn package page lists Android alone.
0.1.0 #
analyze now extracts metrics from every rebuild scope, not only State subclasses.
Added #
analyzeemits a row for each rebuild scope:Statesubclasses,ConsumerWidget/HookConsumerWidgetclasses, and the inline builder callbacks ofBlocBuilder,BlocSelector,BlocConsumer,Consumer,Selector,Obx,GetX,GetBuilder, andObserver, the same kindsisolatedetects.--scope-types/-sonanalyze(repeatable) narrows the emitted kinds;-s Statereproduces the previous output.- New
scopeTypecolumn on every JSONL row, and a per-type breakdown in the run summary.
Changed #
- Breaking (JSONL): the
stateClassNamecolumn is nowscopeName.injectreads either spelling, so manifests produced by earlier versions still work; other downstream consumers must be updated. injectskips manifest rows whosescopeTypeis notState, so a full-scopeanalyzeoutput can be passed to it unchanged.- Scope detection is shared between
analyzeandisolateinstead of duplicated: the kind lists live inAppConstantsand the predicates in the analysis feature's scope detector. - SPM now stands for Scope Performance Metrics (was "State Performance Metrics"), matching what
the tool measures. The package, the
spmexecutable, and every public identifier are unchanged.
Notes #
- Scopes nest, and their metrics overlap on purpose: a
Staterow counts the widgets built inside its nested builder callbacks and each callback gets its own row. Aggregations that sum rows per file should filter byscopeType. instanceIdvalues forStatescopes are unchanged, so existing joins with runtime profiler data still hold.
0.0.3 #
- Export
SpmProfilerfrom the publicpackage:spm/spm.dartAPI. - Add compatibility export paths for profiler imports under
package:spm/features/profiler/presentation/. - Restore support for benchmark and integration-test code that imports
SpmStateandSpmProfilerthrough the profiler presentation path.
0.0.2 #
- Add a public API example for
SpmState. - Document the
SpmStateconstructor for subclass usage. - Widen the analyzer dependency constraint.
- Link the published pub.flutter-io.cn package from the README and wiki.
0.0.1 #
- Initial pub.flutter-io.cn release of SPM.
- Adds CLI commands for Flutter rebuild analysis, validation, profiler injection, profile-mode runs, and rebuild-scope isolation.