Skip to content

Commands analyze

albertoodev edited this page Aug 24, 2026 · 7 revisions

analyze: static analysis

analyze resolves the Dart AST for a Flutter project and finds its rebuild scopes: State subclasses, ConsumerWidget and HookConsumerWidget classes, and callbacks passed to supported state-management builders. It writes one JSONL row of build-tree metrics per scope.

The same output serves as the manifest for inject and run.

Usage

spm analyze --output results.jsonl /path/to/flutter/project
spm analyze -o results.jsonl /path/to/project1 /path/to/project2
spm analyze -v -o results.jsonl /path/to/project   # verbose
spm analyze -o results.jsonl -s State -s BlocBuilder /path/to/project

Options and flags

Flag / Option Short Description
--output <path> -o Required. Output JSONL file path.
--verbose -v Print per-instance progress to stderr.
--scope-types <type> -s Rebuild scope types to extract; repeatable. Defaults to all of State, ConsumerWidget, Consumer, Selector, BlocBuilder, BlocSelector, BlocConsumer, Obx, GetX, GetBuilder, Observer. -s State reproduces the pre-rebuild-scope output.
--package-config <path> The resolved .dart_tool/package_config.json to read every library outside the analysed directories against. Defaults to the one above the first directory.
<dir> [dir…] One or more Flutter project roots to analyze.

On completion it prints a summary to stdout:

Scanned 142 files (3 skipped with compile errors); found 47 rebuild scopes (State: 31, BlocBuilder: 12, ConsumerWidget: 4); kept 47 rows.

How it works

  1. Resolved AST analysis. SPM uses the analyzer package to resolve each Dart compilation unit. Files with error-level diagnostics are skipped and reported in the summary.
  2. Rebuild-scope discovery. RebuildScopeAnalysisVisitor identifies every Flutter State<T> subclass and consumer widget (locating each one's build method), plus every builder callback passed inline to a state-management widget. Scopes nest: a BlocBuilder callback inside a State.build() produces its own row, and the enclosing State row still counts the widgets the callback builds, because a parent rebuild does re-run it.
  3. Build-tree extraction. TreeExtractor follows non-const custom widgets transitively, including the State build of custom StatefulWidget children and declarations in part files. A widget from a package is followed too. It resolves outside the analysed directories, where AnalysisContextCollection.contextFor throws, so SPM builds one collection per package root against the project's own package config. Without that config a pub-cache package cannot resolve its own package:flutter, the index comes back with null types, and every widget in it would classify as a value object.
  4. Interaction callbacks. A closure a rebuild cannot run is not entered, so nothing inside it reaches any feature and a custom widget built there does not seed step 3. The slots are named under Extracted Features, rule 8: handler labels such as onPressed and onChanged, the validator family, and the arguments of deferred hosts such as then and addPostFrameCallback. isolate asks the same predicate through --[no-]prune-non-rebuild, so the isolated file and the in-place row agree on what a rebuild runs.
  5. Metric collection. BuildMetricsVisitor records widgets, helper references, iterations, allocations, list strategy, and layout-dependent builders. Helper bodies are resolved transitively, both those returning a Widget and those returning a collection of widgets. Local functions are read at their call site, so widgets they build inside a loop are counted per element. The AST complexity extension computes build and helper decision points.
  6. Closure recording. Every library the extraction reads is recorded against the scope that asked for it, cache hits included, and so is the verdict on whether it could be read. A library that resolves while carrying an error-severity diagnostic counts as unreadable and is refused rather than read: its types come back null, so its widgets would classify as value objects and the row would be wrong instead of short. The row carries both lists, the resolved version of every package the closure entered (packageVersions), and the non-SDK classes whose build bodies were walked (walkedWidgetClasses).
  7. Normalization and streaming. Metrics are aggregated into an AnalysisResultModel and emitted as one JSONL row per discovered rebuild scope (filtered by --scope-types), followed internally by a summary event.

Output

One JSON object is written per rebuild scope and tagged with its scopeType. Output Formats gives the field reference and an example record. Extracted Features defines each metric.

Each row also names the files its metrics came from (dependencyFiles) and any that could not be read (unresolvedDependencies, summarized by closureResolved). The step-1 skip count in the summary is not the same guard: it covers the file being scanned, while a scope's metrics are read from a closure of files that reaches well beyond it. See closure fields.

Working with the results

Pass one or more feature directories when a whole-project result would be too broad. Use --scope-types State when the output will be used only for SpmState injection; inject can also accept a full manifest and skip the other scope kinds itself.

Drop or flag rows whose closureResolved is 0 before using the metrics quantitatively. Such a row is short by however much its unreadable subtree would have contributed, and nothing else in the output reveals the shortfall.

Pin --package-config when the same project is analysed more than once. Package source contributes to the metrics, so two runs whose pubspec.lock moved between them would otherwise 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. packageVersions makes the pin auditable, so two runs that somehow read different versions are visible rather than silent.

The framework itself is never walked. dart: and package:flutter/ stay outside the traversal, and that is a correctness decision rather than a cost one: the visitor counts every branch of a build body rather than the branch that ran, so walking _ScaffoldState.build would make a Scaffold carrying only a body: count identically to one carrying an appBar, a drawer and a snackbar. A third-party application-level widget does not have that shape, which is why the walk enters one and not the other.

Rows from 0.6.0 or earlier are larger than rows from 0.7.0 for any scope holding a handler, because handler bodies were counted then and are not now. Do not pool the two.

Because scopes can nest, summing every row in a file double-counts work shared by a parent scope and its nested builder callback. Filter or group by scopeType according to the question being asked. JSONL tools such as jq can process the file without loading it as one large JSON array.

Clone this wiki locally