merge/audit-and-state-map #7

Merged
trevon merged 15 commits from merge/audit-and-state-map into main 2026-09-25 00:07:04 +00:00
40 changed files with 4933 additions and 2029 deletions

750
README.md
View file

@ -1,724 +1,76 @@
# pipe
Static state-flow analysis for Dart and Flutter apps. `pipe` resolves a project,
maps how state flows through its state management, and reports the breakages that
make an app behave worse than the code suggests — the ones that show up as a
spinner that never stops, an error the user never sees, or a tap that does
nothing.
Static state-flow analysis for Dart and Flutter apps. `pipe` maps how state
moves through an app's state management and reports the breakages that make it
behave worse than the code suggests: a spinner that never stops, an error the
user never sees, a tap that does nothing.
It is built to guide agents and reviewers on large codebases, not to score style.
It is built to guide agents and reviewers on large codebases, not to score
style. Every finding carries `file:line` evidence, a confidence, a severity and
a remediation, and every report states how much of the project it covered.
## What it covers
## Coverage
| State management | Support |
| --- | --- |
| BLoC / Cubit (`on<Event>`, `emit`, sealed or unsealed state unions) | yes |
| State as a single class with a `bool loading`-style flag (`copyWith` style) | yes |
| Riverpod (`Provider`, `FutureProvider`, `StreamProvider`, `NotifierProvider`, `.family`) | yes |
| `provider` + `ChangeNotifier` | yes, with the limits below |
| GoRouter / navigation, widget layout, theming | no — out of scope |
| BLoC / Cubit (`on<Event>`, `emit`, sealed, abstract, enum or freezed unions) | yes |
| Single state class with a `bool loading`-style flag (`copyWith`) | yes |
| Riverpod (`FutureProvider`, `StreamProvider`, `NotifierProvider`, `.family`, ...) | yes |
| `provider` + `ChangeNotifier` | yes, with [limits](docs/rules.md#provider-and-changenotifier) |
| C++ `enum class` state (experimental, via libclang) | [see design](docs/design.md#c-front-end-experimental) |
| Firestore query inventory (`pipe queries`, no rules) | [see queries](docs/queries.md) |
| Navigation, layout, theming | no |
### `provider` / `ChangeNotifier` support and its limits
Modelled: every provider-package declaration type is collected and reported by
type in the coverage note (`Provider`, `ChangeNotifierProvider`,
`ListenableProvider`, `ValueListenableProvider`, `FutureProvider`,
`StreamProvider`, `StateProvider`, `ProxyProvider` and its variants, `.value`
constructors), along with `Consumer`, `Selector`, `ChangeNotifierProvider`,
`ListenableBuilder` and `AnimatedBuilder` as consumption sites. Declaration
detection uses the type name plus `create:`/`update:`/`value:` named arguments to
tell the provider package apart from Riverpod's positional `(ref)` callback — it
is not import-verified, so an unrelated class named `Provider` taking a `create:`
argument would be miscounted in that note.
Rules: `notifier-never-notifies`, `notifier-inert-consumer`,
`provider-read-in-build`, `notifier-field-unread`, `provider-created-in-build`.
Still not implemented, and not claimed: any analysis of `ProxyProvider`
`update` functions returning unchanged values, and rebuild-frequency reasoning
(which is why `provider-created-in-build` is info rather than a warning — the
pattern is harmless on a widget that never rebuilds and a real leak on one that
rebuilds often, and static analysis cannot tell them apart).
The `provider-read-in-build` rule does not descend into closures, because a read
inside a callback or a `Selector` builder is not made while build runs. That is a
deliberate precision choice: it costs recall on reads inside a
`Builder(builder: ...)`, which is noted rather than hidden.
Not modelled today: `bloc_concurrency` transformers, freezed unions whose
generated file is absent, and `ref.read` call sites (dispatch, not render).
## Usage
## Quick start
```bash
dart pub get
tool/build_release.sh # prints the binary's path under build/
# below, `pipe` is that binary on your PATH (or `dart run bin/pipe.dart`)
# Is the project even analyzable? (resolution health, timing, diagnostics)
pipe index <path>
# The resolved project model (declarations) as JSON
pipe graph <path>
# The state-flow graph: unions, emissions, render sites, coverage, Riverpod
pipe state <path>
# Ranked breakages with evidence and remediation
pipe check <path>
# A prompt-sized brief for an agent, optionally persisted into the yams corpus
pipe guide <path> --budget 6000 --yams
# How many labelled known bugs does pipe actually detect?
pipe recall --root <dir-with-repos> --strict
# After a fix: which accepted findings are gone?
pipe verify <path> --baseline .pipe-baseline.json --expect-resolved
# What to fix first, per file (ranked, with the scoring explained)
pipe suggest <path> --format agent --budget 3000
# Every Firestore query the code can issue, as typed records (inventory only)
pipe queries <path> --format json [--adapter-config adapters.json]
pipe index <path> # can the project be analysed?
pipe check <path> # ranked findings
pipe suggest <path> # what to fix first, per file
pipe guide <path> --format agent # a brief to paste into an agent prompt
pipe queries <path> # every Firestore query, as typed records
```
Common flags: `--format text|json` (`agent|md|json` for `guide`,
`text|agent|json` for `suggest`), `-c N` (concurrency, default 4; 8 for
`recall`), `--sdk <path>` (override SDK discovery), `--dir` (repeatable,
default `lib`; a directory that does not exist is an error, not skipped),
`--[no-]include-generated` (default on: `*.g.dart`, `*.freezed.dart` and
`*.mocks.dart` are scanned unless you pass `--no-include-generated`),
`--limit N`, `--rule <name>` (repeatable), `--top N`.
Command-specific flags: `queries --format json|md` (default `json`),
`--adapter-config <file>` (see [Firestore query inventory](#firestore-query-inventory))
and `--require-firestore` (exit 2 when cloud_firestore types do not resolve);
`index --fail-on-error` exits 1 when the resolved project
has analyzer errors (the default reports the count and exits 0);
`suggest --budget N --max-actions N --top N` bound the agent plan and the text
report; `guide --budget N --max-findings N` bound the agent brief, and `--yams`
with `--yams-name <name>` persists the full markdown report (not the
budget-trimmed brief) to the yams corpus, tagged with the analysed commit and a
generation timestamp;
`recall --manifest <path> --root <dir>` locate the labelled-bug manifest and the
directory holding the repos it names (without `--manifest`, `recall/manifest.json`
in the working directory, else the one in the pipe checkout the binary was built
from), and `recall --strict` exits non-zero when a
case it expects to still find is missed. Every numeric flag rejects a value that
is not a positive integer rather than falling back to its default.
Output contract:
- **stdout carries only the requested output.** Status lines such as
`wrote baseline to ...` and `persisted to yams as ...`, and warnings such as a
partial scan, go to stderr, so `--format json` output always parses.
- **Every report states its coverage.** `check`, `guide` and `suggest` JSON carry
a `coverage` block `{discovered, selected, resolved, failed, skipped,
complete}`, and the text outputs, the guide brief and the suggest agent plan
open with a one-line coverage note that says PARTIAL whenever a discovered
file was not analysed. `skipped` counts files the analyzer does not cover:
those your `analysis_options.yaml` `exclude:` names, or files outside any
analysis context. The note is part of the brief's header, so budget trimming
never drops it.
- **Exit codes:** 0 success; 1 a gate failed (`check --fail-on`,
`verify --expect-resolved`, `recall --strict`, `index --fail-on-error`, or
`guide --yams` could not persist); 2 pipe could not do what was asked (a bad
path or `--dir`, an unreadable project, a baseline that cannot be read or
written, or an internal error - rerun with `PIPE_DEBUG=1` for its stack
trace); 64 invalid usage, including more than one project path.
`guide --yams --write-baseline` still writes the baseline when yams fails.
`pipe` finds the right SDK itself: it reads `flutterRoot` from the target's
`.dart_tool/package_config.json`, then `FLUTTER_ROOT`, then `flutter` on `PATH`.
Flutter projects need Flutter's bundled Dart SDK because `dart:ui` is not part of
the standalone SDK. If the project has never been resolved, `pipe` says so and
tells you to run `pub get` rather than silently degrading every `package:` import
to `dynamic`.
## CI and ratcheting
An existing codebase will have findings on day one. Accept them once, then fail
only on what is new:
In CI, accept today's findings once and fail only on new ones:
```bash
pipe check . --write-baseline .pipe-baseline.json # accept current state
pipe check . --write-baseline .pipe-baseline.json
pipe check . --baseline .pipe-baseline.json --fail-on warning
pipe verify . --baseline .pipe-baseline.json --expect-resolved # after a fix
```
`--fail-on none|info|warning|error` (default `none`) fails the build when a
**new** finding at or above that severity is present. Baselined findings never
trip the gate, so a ratcheted repo stays green while a regression fails.
Because today's signal is mostly warnings, `--fail-on warning` is the useful
setting; `--fail-on error` only catches the strongest rules (`--fail-on-finding`
is an alias for it).
`pipe verify` closes the other half of the loop — proving a fix rather than only
reporting a problem. It compares a fresh analysis against the baseline and prints
what is `resolved`, what `still` remains, and what is `new`, exiting non-zero with
`--expect-resolved` while anything accepted is still reported or was not
scanned:
```text
$ pipe verify . --baseline .pipe-baseline.json
baseline 12 accepted findings
resolved 1 no longer reported — these fixes are confirmed
still 11 still reported
new 0 not in the baseline
RESOLVED
+ lib/flag_cubit.dart:25 loadUnguarded [loading-flag-not-reset]
```
Baselines are self-describing (each entry carries the rule plus a readable label),
so a resolved finding is named rather than shown as an opaque hash.
`verify` only calls a finding resolved when it looked for it:
- With `--rule`, accepted findings of other rules are set aside as out of scope
(counted, not judged), because a rule that did not run reports nothing.
- An accepted finding in a file this scan did not analyse — excluded by
`analysis_options.yaml`, filtered by `--no-include-generated`, or outside every
`--dir` — is listed as **NOT SCANNED**, never as resolved.
- A scan in which any file failed to resolve, or one truncated by `--limit`, is
refused outright.
A baseline stores a **fingerprint** per finding (`rule + file + semantic
identity` such as union/variant/provider/owner/member; for two swallowed catches
in one method, the catch's index within that method), so moving code does not
resurrect accepted findings. Baselines are written as `"version": 2`. Version-1
baselines, whose fingerprints embedded a line number for four rules
(`emitted-not-rendered` on a dormant union, `notifier-field-unread`,
`notifier-inert-consumer`, `loading-not-reset`), are still matched, but those
entries stay line-sensitive until you rewrite the baseline with
`--write-baseline`. `--fail-on-finding` only counts new
error-severity findings, and severity counts in reports are computed over new
findings, so a ratcheted baseline does not keep a build red.
Baselines and reports are JSON, so they diff and review like any other file.
## Rules
| Rule | What it means | Verified precision (with n) |
| --- | --- | --- |
| `emitted-not-rendered` | A state the bloc emits that no UI code matches, so the user never sees it. | **79/79 (100%)** factual on a CLEAN nuwayze main via an independent adjudicator with passing controls (`tool/adjudicate.py`). Reported once per **root cause**, not once per variant: one finding per unobserved union (dormant feature) and one per marker class when the union is observed. When the UI reads the union's payload through **base-class fields** the finding drops to info, because the data does reach the user. On clean nuwayze main: 30 findings covering 79 variant-level claims. |
| `rendered-not-emitted` | A UI branch for a variant its bloc never emits in the scanned code: probably dead UI. | n=0 on nuwayze (unions whose bloc emits through helper methods are suppressed with a reason). Fires on a fixture with a passing negative control. |
| `loading-not-reset` | An in-flight state whose failure path never resolves, so the spinner sticks. | n=0 on both dogfood repos, both verified to guard correctly; proven able to fire by fixture. |
| `loading-flag-not-reset` | The same consequence for `copyWith` + `bool loading` state, which has no variant to match on. | **7/7 (100%)** on dex after hand adjudication; 4 characterised false positives were eliminated by the current gate. n=0 on nuwayze, verified correct. |
| `event-without-handler` | A dispatched event with no `on<Event>`, so the interaction does nothing. | n=0 across the corpus; verified non-vacuous (168 dispatch sites on nuwayze all map to a registered handler). |
| `dead-handler` | An `on<Event>` nothing ever dispatches or constructs. | **77/77 (100%)** on nuwayze, same adjudicator with passing controls. |
| `riverpod-async-gap` | An async provider read in the UI whose error and/or loading state is never rendered. | n=8 on word_alive, n=2 on tick, hand-adjudicated: **warning/high** only for data-carrying providers; `bool` providers read as optimistic guards are downgraded to info/medium after a confirmed false positive. |
| `notifier-never-notifies` | A `ChangeNotifier` method that changes observable state without ever notifying listeners. | Fixture contract only: **zero true positives** on the corpus (nuwayze 30 observable mutators, word_alive 36 — all of which notify). Gated on observable fields with lifecycle hooks excluded, after the first run was 9/9 false positives. |
| `notifier-inert-consumer` | A notifier consumed by a `Consumer`/`Selector` with no notifying path at all, so those consumers can never rebuild. | Fixture contract only; no real-code true positives yet. |
| `provider-read-in-build` | A `Listenable` read with `context.read` / `Provider.of(listen: false)` **directly in a build body**, so the widget never subscribes and can render stale state. | 1 finding on nuwayze from **447** raw `context.read` calls; 0 on word_alive/dex. Hand-verified: the first 4 findings were all reads inside **closures** (a `BlocListener` callback, a `Selector` builder), so a closure guard was added and only the direct-in-build case survives. |
| `notifier-field-unread` | A public getter on a consumed `ChangeNotifier` that nothing reads, so the state it exposes likely never reaches the UI. | 3 on nuwayze (e.g. `UserProvider.isInitializing`, `AppLocaleProvider.preferredLocale`), 0 on word_alive/dex. Reported once per notifier, and only for notifiers the UI actually consumes. **medium** confidence: a name match elsewhere can mask a genuinely unread member. |
| `provider-created-in-build` | A provider constructed inside `build` without `.value`, so if the widget rebuilds, `create` reruns and its state is lost. | 1 finding on nuwayze — `AppProviders.build` constructs **45** providers — from 45 raw sites, because it is reported **once per build method**. Deliberately **info**, not warning: the claim is factual, but a root `StatelessWidget` rarely rebuilds, so impact depends on rebuild frequency. |
### How precision was measured
`tool/adjudicate.py <repo>` re-derives the two highest-volume claims without
trusting pipe's bookkeeping, and **prints controls first**. It must find evidence
for every variant pipe calls *rendered* and every event pipe calls *dispatched*;
on clean nuwayze main that is 131/131 rendered (type tests, `case`/switch-
expression patterns, or base-class payload reads) and 143/162 dispatched, the
other 19 being shared event names whose construction sites a text-level checker
cannot honestly attribute — reported as *unattributable*, not as a pass.
The control is deliberately three-way — **found / unattributable / missing** — and
only *missing* fails it, because an oracle that cannot discriminate must say so
rather than report a clean bill of health. A corpus with no control material at
all exits with **NO SIGNAL** and calls precision **undefined, not perfect**. If a control is
below 100% the numbers after it are void — which is exactly what happened on the
first attempt: an earlier version of the checker matched the `on<Event>`
registration as if it were a dispatch and reported 9% precision. The tool also
classifies comment-stripped lines, because a commented-out dispatch
(`cartview.dart:512`) is not a dispatch.
Re-run the adjudicator on a **clean tree**: on a repo with uncommitted edits its
controls correctly fail and it refuses to certify, which is the behaviour you
want from an oracle.
The adjudicator deliberately judges the **factual claim only** ("never
type-tested in UI" / "never constructed elsewhere"). It does not judge UX
severity, and it runs on one repo, so it bounds false-positive risk rather than
eliminating it.
### A factually correct finding is not always a bug
`emitted-not-rendered` at high confidence means "no UI code observes this state
type". That is verifiable and true — but it can be *deliberate*. On nuwayze the
two such blocs turned out to be dormant scaffolding the repo had already
documented as unreachable:
- `AdminSettingsBloc` never had a UI consumer (the shipped admin Settings tab
uses `ArtistSettingsBloc` and reads the repository directly), and the project's
own action contract classifies `UpdateFeatureFlag` / `SaveAdminSettings` as
`"not-reachable"`.
- `CommentsBloc` was deliberately retired when a `StreamBuilder`-driven comments
UI replaced it; the last consumer was deleted in a cleanup commit.
So pipe now reports **one finding per unobserved union** instead of one per
variant (that change took nuwayze from 159 findings to 146, collapsing 15 variant
findings into 2 decisions), and the remediation says to check the repository's
docs and action contracts before deleting anything. Treat those as decisions to
confirm, not regressions to fix.
**A negative result worth recording.** The obvious next tier looked promising: a
never-matched variant that declares *no* fields beyond its base cannot hide any
data of its own, so it should be harmless. Measured on nuwayze, **all 67** of the
medium-tier findings qualified — every one was a marker state carrying no payload
of its own. A signal that is true of 100% of the population cannot separate
signal from noise, as a *discriminator*. It was still the right question though:
an audit of the same repo found the real cause, which is that the UI reads the
union's payload off the **base class** (`state.currentBooking`,
`state.isRefreshing`), so the data reaches the user without the variant ever being
matched. `uiReadsBaseFields` now records that per union and drops those findings
to info with an explanation, which removed 5 false-positive warnings on nuwayze.
**A recall bug the same audit surfaced.** The switch
`case ChatListLoaded value => ...` binds a type on a *variable* pattern, not the
object pattern `ChatListLoaded()` this matcher looked for, so an exhaustive switch
was invisible and two variants were wrongly reported as unrendered. Both the
mention collector and the branch collector now handle `DeclaredVariablePattern`.
The fixture has a regression guard (`patternView`) so this cannot come back.
This is also why the precision numbers above are stated as *factual* precision:
they say how often the claim holds, not how often the issue is worth acting on.
Every finding carries `file:line`, a `path:line: excerpt` evidence string (string
literals elided, at most 120 characters), a confidence, a UX severity, and a
remediation. If a rule cannot be validated it is
demoted or deleted, not defended.
### Recall, measured not assumed
Precision alone is not evidence. Two mechanisms pin recall:
1. **Rule contracts in CI.** `test/rule_contract_test.dart` requires every rule to
have a positive control (it must fire) *and* a negative control (an adjacent
case it must stay silent on), from fixture code written for exactly that pair —
for example `loading-flag-not-reset` must fire on `loadUnguarded` and must not
fire on `loadGuarded` or `refreshQuietly`; `event-without-handler` must fire on
`UnhandledTap` and must not fire on `NeverDispatched`, which is handled. A rule
added without both controls fails the build.
2. **Labelled real bugs.** `pipe recall` evaluates `recall/manifest.json` — real
bugs previously found in real codebases — and reports `ok/total` per rule,
failing (`--strict`) on any miss. Repos absent from the machine are reported as
**skipped**, never as passes.
Each case carries a `status`:
- `open` — the bug still exists, so pipe must **find** it.
- `fixed` — the bug was repaired, so pipe must **not** find it again. A `fixed`
case that reappears is reported as a **REGRESSION**, which turns every repair
into a permanent guard against the real code rather than a deleted
expectation.
Result as of 2026-09-20: **5/5 accounted for** (2 open bugs found, 3 fixed bugs
still quiet), 0 regressions — measured against the **working tree**, so a repo
checked out on an unmerged fix branch reports its cases as missed. At that date
the two nuwayze cases were wired up on branches (`wire/nuwayze-*`) that were not
yet merged, so they read as `open`; they flip to `fixed` when those land.
Recall is still measured against a small labelled set. It bounds the risk of
false negatives; it does not prove they are absent.
### What pipe deliberately does not reimplement
`unawaited_futures`, `use_build_context_synchronously`, `cancel_subscriptions`,
`close_sinks`, BLoC naming/API hygiene (`bloc_lint`), declaration-level dead code
(`undead`, `dallow`). Those are per-file checks the ecosystem already does well.
pipe targets whole-program state reachability that per-file linters structurally
cannot express.
## Default remediation policy
**Wire it up.** An unreachable state, handler or control is usually an *unfinished
feature* rather than dead weight, so pipe's default guidance is to make it
reachable: render the missing states, add the missing branch, dispatch the event
from a control. Removal is the exception and is worded as needing explicit
approval rather than offered as an equal choice.
`--policy wire-up` (default) / `--policy remove` flips the emphasis for teams that
would rather delete. It changes the wording of every remediation that presents a
choice, the fixes in the brief's rule legend, the `decision:` line in the agent
brief, the heading of the dead-handler section (`Unreachable handlers - wire up
the control` versus `Cleanly deletable`), and whether `suggest` scores a dead
handler as needing no product judgement (only under `remove`).
This is a policy, not a finding: pipe still reports the same facts either way.
## Fix order
`pipe suggest` turns a findings list into a work plan: findings are collapsed to
root causes (one action per union / provider / handler), each cause is scored, and
the plan is presented both globally and **per file**. Only new findings are
planned, so a ratcheted baseline yields an empty plan.
```text
# pipe plan — test/fixtures/stuck_loading
Static heuristics; confirm each against the cited file. Order is a score, not a mandate.
Coverage: 12 of 12 discovered file(s) analysed (0 failed, 0 skipped by analysis_options or context).
20 findings collapse to 16 root cause(s) across 8 file(s).
1. [error/high] event-without-handler — HandlerGapEvent (GapBloc)
why: 115 = error 100 x high (x1.0), no product judgement needed (+15)
fix: Add `on<UnhandledTap>(...)` to GapBloc, or stop dispatching UnhandledTap.
where: lib/gaps.dart:74
clears 1 finding(s) (UnhandledTap); verify with `pipe check test/fixtures/stuck_loading --rule event-without-handler`
2. [warning/high] loading-not-reset — ItemBloc.twoSwallowedAwaits
why: 85 = warning 50 x high (x1.0), one fix clears 3 findings (+20), no product judgement needed (+15)
fix: Emit a failure/timeout state in the catch (or rethrow) so every loading state is resolved. Applies to each of: <closure>, twoSwallowedAwaits.
where: lib/item_bloc.dart:35, lib/item_bloc.dart:40, lib/item_bloc.dart:53
clears 3 finding(s) (<closure>, twoSwallowedAwaits); verify with `pipe check test/fixtures/stuck_loading --rule loading-not-reset`
14 further cause(s) omitted to stay within 1500 characters. Run `pipe suggest` for the full plan.
```
(Real output of `pipe suggest test/fixtures/stuck_loading --format agent
--budget 1500`, run from the repository root. The budget cut the plan to its top
two causes; the omission line says so.)
The score is deliberately simple and always printed, so the order can be argued
with rather than trusted blindly:
| term | effect |
| Rule | Meaning |
| --- | --- |
| severity | `error` 100, `warning` 50, `info` 10 — dominates the **base** score |
| confidence | high ×1.0, medium ×0.7, low ×0.4 — a low-confidence finding may not be real |
| breadth | +10 per extra finding the one fix clears (capped at +50) |
| user-visible failure | +25 when the rule flagged the failure as UX-critical |
| certainty | +15 for rules whose fix needs no product judgement (event without handler, loading not reset; dead handler only under `--policy remove`, because wiring one up means choosing the control that dispatches it) |
| `emitted-not-rendered` | A state is emitted but no UI code matches it, so the user never sees it. |
| `rendered-not-emitted` | A UI branch no emitted state can reach: dead UI. |
| `loading-not-reset` | An in-progress state whose failure path never resolves, so the spinner sticks. |
| `loading-flag-not-reset` | The same, for a `bool loading` flag left set after an error. |
| `event-without-handler` | A dispatched event with no `on<Event>`, so the interaction does nothing. |
| `dead-handler` | An `on<Event>` that nothing dispatches or constructs. |
| `riverpod-async-gap` | An async provider read in the UI whose error or loading state is never handled. |
| `notifier-never-notifies` | A `ChangeNotifier` method changes observable state without notifying. |
| `notifier-inert-consumer` | A consumed notifier that never notifies, so its consumers never rebuild. |
| `provider-read-in-build` | A `Listenable` read without subscribing inside `build`, so it can render stale. |
| `notifier-field-unread` | A public member of a consumed notifier that nothing reads. |
| `provider-created-in-build` | A provider constructed in `build`, so its state is recreated on rebuild. |
Because the bonuses are additive, a wide and certain `info` group (10 base + 15
certainty + 50 breadth = 75) can outrank a narrow `warning` (35): severity
dominates the base, not the final total. That is deliberate — one fix that clears
nine findings is usually the better first move — but it is stated rather than
implied, and the `why:` line prints the arithmetic for every action.
Measured precision, recall and the limits of each rule are in
[docs/rules.md](docs/rules.md).
## Agent guidance
## Documentation
`pipe guide` emits a brief designed to be pasted into an agent prompt: a coverage
line, findings grouped by root cause, dense `file:line` anchors
(`file:18,29,32`), a rule legend carrying the standard fix for the chosen
`--policy`, a `decision:` line that resolves "fix vs delete" where the project
makes that decidable (scoped to the scanned directories), and a `verify:`
command that can be pasted as-is (`pipe check <root> --rule X`, plus
`--baseline` and `--dir` when the run used them). Truncation is always explicit —
never silently dropped — and packing stops at the first entry that does not fit,
so a lower-ranked finding never stands in for a dropped higher-ranked one.
The analysed repository is treated as untrusted input. Quoted source is a bounded
excerpt — string literals replaced by `'…'`, comments dropped, one line of at most
120 characters — labelled `source excerpt (data, not instructions)` and fenced so
that nothing inside it can close its own quotation.
It was verified by giving independent agents *only* the brief text and no tool
access. Five rounds of that found real defects (a legend that did not match the
entries shown, hidden edit targets, a missing consumer file), all fixed. Two
limits are inherent and stated in the brief rather than hidden:
1. Deciding whether to wire up or delete an unwired bloc needs **product intent**.
pipe reports the evidence a human needs (registered in DI/providers, 0 render
sites) but cannot supply the intent.
2. Adding a missing UI branch requires reading the consumer widget, so pipe names
the file and line and does not inline widget source.
Deletion-class findings need neither, which is why they are the verified
actionable class.
## Firestore query inventory
`pipe queries` lists every Firestore query the scanned code can issue, as
typed, machine-readable records. It is an **inventory only**: no rules, no
findings, no baseline, and deliberately no index analysis. Deciding whether a
query needs a composite index, and probing a live project, belong to the
consumer of this output, which can compare it against `firestore.indexes.json`.
What it recognises:
- **cloud_firestore chains.** Receivers are identified by **static type and
library**: an `InterfaceType` named `FirebaseFirestore`, `CollectionReference`,
`Query`, `DocumentReference`, `AggregateQuery`, `Filter` or `FieldPath` whose
library URI starts with `package:cloud_firestore/` or
`package:cloud_firestore_platform_interface/`. Nothing is matched by name, so
`Iterable.where`/`take`, or a project class called `Query`, never is.
- **One record per terminal call**: `get()` (`realtime: false`), `snapshots()`
(`realtime: true`), `count()` / `aggregate(...)` (`aggregate: count|sum|average`)
on a `Query` or `CollectionReference`. `DocumentReference.get/snapshots` are
point reads that need no index: they are counted in `totals.documentReads`
and never listed.
- `collection(path)`, `collectionGroup(id)` and `doc(...)` segments become
`scope`, `collection` (the last collection id) and a `path` pattern such as
`stores/*/products`. Document ids are expected to be dynamic and cost
nothing; a dynamic **collection** id makes the record `partial`, and a fully
dynamic one also sets `collection: null`.
- `where(...)` with every named operator (`isEqualTo` `==`, `isNotEqualTo` `!=`,
`isLessThan` `<`, `isLessThanOrEqualTo` `<=`, `isGreaterThan` `>`,
`isGreaterThanOrEqualTo` `>=`, `arrayContains` `array-contains`,
`arrayContainsAny` `array-contains-any`, `whereIn` `in`, `whereNotIn`
`not-in`, `isNull: true` `==` null, `isNull: false` `!=` null); a field may be
a string, `FieldPath([...])` (dotted) or `FieldPath.documentId` (`__name__`).
`where(Filter.or(...))` / `Filter.and(...)` become a `composite` node.
- `orderBy(field, descending:)`, `limit`, `limitToLast` (`limit.last: true`),
and any cursor (`startAt`, `startAfter`, `endAt`, `endBefore` and their
`...Document` forms) as `cursor: true`. `withConverter` passes through.
- Strings resolve through literals, adjacent and interpolated strings (`*` for
each unknown part), `+`, `const` and `final` variables with a literal
initializer, and one-return path helpers (`storePath(id) => 'stores/$id'`)
with their arguments bound.
- **Built queries.** A local `Query` variable is the union of its definitions in
its function body; a clause added under an `if`, ternary, `switch`, loop,
`catch` or closure is `conditional: true`. Two cuts keep that union honest:
an assignment textually after the terminal is ignored unless both sit in one
loop, and an `if` that encloses both the assignment and the terminal does not
make the clause conditional. Fields are followed through their initializer,
constructor initializers and assignments in their class.
- **Helpers.** A getter, method or function in the project that returns a query
is summarised and inlined at its call sites with the call's arguments bound.
Methods are inlined **one level deep**; getters and fields take no arguments
and do not count against that. A query that arrives as a parameter, or from a
call pipe cannot follow, is `origin: unknown`, `confidence: unknown`, with
the clauses added locally still recorded.
- `inLoop: true` when the terminal is inside a `for`/`for-in`/`while`/`do` body
or a collection `for`, or inside a closure passed to `Iterable.map`/
`forEach`/`expand`, `Stream.asyncMap`, or within `Future.wait(...)`. A fixed
`Future.wait([a.get(), b.get()])` is not a loop.
### Schema
```json
{
"version": 1,
"root": "relative path, or the directory name when outside the cwd",
"coverage": {"discovered": 827, "selected": 827, "resolved": 825, "failed": 0, "skipped": 2, "complete": false},
"totals": {
"queries": 372,
"byConfidence": {"exact": 336, "partial": 28, "unknown": 8},
"byScope": {"collection": 297, "collectionGroup": 66, "unknown": 9},
"byOrigin": {"chain": 189, "helper": 47, "unknown": 8, "variable": 128},
"realtime": 28, "inLoop": 37, "documentReads": 165,
"collections": {"bookings": 23, "...": 0}
},
"unresolvedFirestoreUris": [{"site": "lib/a.dart:3", "uri": "package:cloud_firestore/cloud_firestore.dart"}],
"notes": ["why the inventory may be incomplete; never empty when queries is"],
"queries": [{
"site": "lib/services/category_service.dart:23",
"owner": "CategoryService", "member": "getCategories",
"scope": "collection", "collection": "categories", "path": "categories",
"filters": [{"field": "isActive", "op": "==", "conditional": false, "valueType": "bool"}],
"orderBy": [{"field": "name", "dir": "asc", "conditional": false}],
"limit": {"kind": "none", "conditional": false},
"cursor": false, "realtime": false, "inLoop": false,
"confidence": "exact", "origin": "chain", "reasons": []
}]
}
```
- `filters[].valueType`: `string`, `int`, `double`, `num`, `bool`, `timestamp`
(`DateTime` or `Timestamp`), `null`, `list`, `map`, `reference`, or
`unknown`; `inCount` is the length of a literal list operand.
- `composite` (optional): `{op: or|and, filters: [filter | {composite}]}`.
- `limit`: `{kind: literal|variable|none, value?, conditional, last?}`.
- `orderBy[].dir`: `asc`, `desc`, or `unknown` when `descending:` is not a
constant.
- `confidence`: `exact`, `partial` (the source is known but something is not:
every cause is in `reasons`), or `unknown` (the query's source could not be
followed). `origin`: `chain`, `variable`, `helper`, `unknown`, `adapter`.
- `queries` is sorted by site. Status lines and notes go to stderr, so stdout
is exactly the document. `--format md` renders the same data grouped by
collection, with the coverage line first.
**Injection policy.** The analysed repository is untrusted (see
[Agent guidance](#agent-guidance)). The inventory never quotes source: every
collection, path and field name must match `^[A-Za-z0-9_.*/\-]+$` or it is
replaced with `*` and a reason is added; owners and members are identifiers.
**Unresolved packages.** If `cloud_firestore` is not in the package graph, or
an import of it does not resolve, the output says so in `notes` (and
`unresolvedFirestoreUris`) rather than presenting an empty list as a clean
result; `--require-firestore` turns that into exit 2.
### Adapter config
Server-side code often wraps Firestore in a declarative API that takes a query
as data, so there is no chain to follow. `--adapter-config` (YAML, or JSON,
which is a subset of YAML) tells pipe what such an API's arguments mean. The
resulting records use the same schema with `origin: adapter`. For a gateway
like this:
```dart
abstract class FirestoreGateway {
Future<List<QueriedDoc>> queryCollection(String collectionPath,
{List<WhereEquals> filters = const [], WhereArrayContains? arrayContains,
List<WhereRange> ranges = const [], List<OrderBy> orderBy = const [],
String? startAfterId, int? limit});
Future<List<QueriedDoc>> queryCollectionGroup(String collectionId,
{List<WhereEquals> filters = const [], WhereIn? inFilter,
WhereArrayContainsAny? arrayContainsAny, List<WhereRange> ranges = const [],
List<OrderBy> orderBy = const [], int? limit});
}
abstract class FirestoreTransaction {
Future<List<QueriedDoc>> queryCollection(String collectionPath,
{List<WhereEquals> filters = const [], int? limit});
}
// WhereEquals(field, value), WhereIn(field, values), WhereArrayContains(field, value),
// WhereArrayContainsAny(field, values), WhereRange(field, {gte, gt, lte, lt}),
// OrderBy(field, {descending = false})
```
the config is:
```json
{
"adapters": [{
"interface": ["FirestoreGateway", "FirestoreTransaction"],
"library": "package:nuwayze_functions_dart/src/data/firestore_gateway.dart",
"methods": {
"queryCollection": {"scope": "collection", "path": 0},
"queryCollectionGroup": {"scope": "collectionGroup", "path": 0}
},
"args": {
"filters": "filters", "arrayContains": "filters", "arrayContainsAny": "filters",
"inFilter": "filters", "ranges": "filters",
"orderBy": "orderBy", "limit": "limit", "startAfterId": "cursor"
},
"types": {
"WhereEquals": {"field": 0, "op": "==", "value": 1},
"WhereIn": {"field": 0, "op": "in", "value": 1},
"WhereArrayContains": {"field": 0, "op": "array-contains", "value": 1},
"WhereArrayContainsAny": {"field": 0, "op": "array-contains-any", "value": 1},
"WhereRange": {"field": 0, "ops": {"gte": ">=", "gt": ">", "lte": "<=", "lt": "<"}},
"OrderBy": {"kind": "orderBy", "field": 0, "descending": "descending"}
}
}]
}
```
- `interface` (a name or a list) + `library`: a call matches when the invoked
method's enclosing class is one of these interfaces from that library, or a
class implementing one. Names alone never match.
- `methods.<name>`: `scope` (`collection` | `collectionGroup`), `path` (the
argument holding the path or group id), optional `realtime: true`.
- `args.<named argument>`: a role - `filters` and `orderBy` (a value object, a
list literal with `if`/`for`/spread elements, a ternary, or a local list built
with `add`/`addAll`; elements under an `if` are conditional), `limit`, or
`cursor` (any non-null value).
- `types.<class>`: how to read one value object, matched by class name and
library (the adapter's, or `library:`). `field` and `value` are a positional
index or a named argument. The operator is either fixed (`op`), one per
present named bound (`ops`), or read from an argument (`opArg`) and mapped
through `opMap`, keyed by enum constant name or string value
(`{"opArg": 1, "opMap": {"lessThan": "<", "greaterThan": ">"}}`).
`kind: orderBy` with `descending` reads an ordering instead of a filter.
- Every operator must be one of `==`, `!=`, `<`, `<=`, `>`, `>=`,
`array-contains`, `array-contains-any`, `in`, `not-in`; a config that names
anything else, an unknown role, or a bad scope is refused with exit 2.
### Limits
- Flow-insensitive within a function, apart from the two cuts above: a clause
on a path that returns early is still in the union (as conditional).
- Helper methods are inlined one level deep. A query-consuming wrapper - a
method that takes a `Query` parameter and calls `get()` itself - is reported
at its own terminal as `unknown`; the clauses its callers pass in are not
attributed to it.
- A collection name taken from a loop variable over a constant list, a map
lookup or a runtime value is `*`, with the record `partial`.
- No index analysis, by design: the output carries what an index check needs
(scope, collection group, equality vs. range vs. array operators, order
directions, `in` sizes) and leaves the decision to the consumer.
Calibrated on the NuWayze app (Flutter, `lib/`, 827 files) and its Dart backend
(`functions-dart`, 147 files, with the config above), 2026-09-23:
| package | extracted | independent count | exact / partial / unknown |
| --- | --- | --- | --- |
| app | 372 query terminals + 165 document reads | 372 `get`/`snapshots`/`count`/`aggregate` calls whose receiver resolves to `Query`/`CollectionReference` (336 + 36) | 336 / 28 / 8 |
| backend | 136 adapter calls | 136 `queryCollection(`/`queryCollectionGroup(` call sites by grep | 121 / 15 / 0 |
Every `partial` is a genuinely dynamic collection or field name; the 8
`unknown` are query-consuming wrappers (a `Query` parameter, or a wrapper that
applies caller-supplied builder closures). Zero false positives were found:
records exist only where the receiver type resolves into cloud_firestore.
## Architecture
```text
bin/pipe.dart entrypoint, exit codes
lib/src/cli.dart commands and option parsing
lib/src/errors.dart PipeException (message + remedy)
lib/src/project.dart package graph validation, source discovery
lib/src/sdk.dart SDK discovery (Flutter-aware)
lib/src/analysis.dart AnalysisContextCollection lifecycle, per-file
resolution, scan coverage
lib/src/pool.dart bounded-concurrency map over files
lib/src/index.dart resolution health, timing, diagnostics
lib/src/model.dart declaration model
lib/src/state_flow.dart the state-flow IR (unions, emissions, render sites,
events, flags, Riverpod)
lib/src/firestore_facts.dart Firestore query inventory (typed records, no
rules)
lib/src/gateway_facts.dart adapters for declarative query APIs
lib/src/excerpt.dart bounded, literal-free source excerpts
lib/src/rules.dart breakage detectors, findings, baselines
lib/src/provider_rules.dart provider / ChangeNotifier rules
lib/src/recall.dart labelled-bug recall measurement
lib/src/verify.dart baseline verification (resolved / still / new /
not scanned)
lib/src/suggest.dart ranked fix order per file
lib/src/pipeline.dart shared analyze -> graph -> rules pipeline
lib/src/guide.dart agent brief / markdown report / yams emission
tool/adjudicate.py independent precision adjudicator
tool/build_release.sh native AOT build
```
Built on `package:analyzer` with whole-project resolution, not a
`custom_lint`/analyzer plugin: plugins have no project-wide index, `flutter
analyze` does not report new-system plugin diagnostics, and a CLI can pin its own
analyzer instead of chasing the SDK's pinned range.
Resolution mirrors Flutter's own tooling (dispose and rebuild the context
collection when `package_config.json` changes, `changeFile` +
`applyPendingFileChanges`, a fresh session after
`InconsistentAnalysisException`).
## Performance
Run pipe as a native binary, not through `dart run`. Dart JIT startup is paid on
every invocation and dominates short runs:
```bash
tool/build_release.sh # dart build cli, falling back to dart compile exe
```
Measured on the same machine (macOS arm64):
| scenario | `dart run` (JIT) | AOT binary |
| --- | --- | --- |
| 2-file project | 6.4s | **0.2s** |
| 814-file Flutter app, end to end | ~23-29s | **10.4s** |
| per-file p50 on that app | ~50-75ms | **16ms** |
The binary is ~18MB and self-contained.
### Where the remaining time goes
The first file of a large project costs ~3.1s (analyzer warmup: SDK and package
summaries), and the rest is aggregate per-file resolution. SDK *discovery* is not
the floor - it is a couple of file checks against `package_config.json` - so the
obvious "cache the SDK path" optimisation buys nothing. Cutting the warmup would
need a persistent analysis cache, which is a much larger and riskier change.
Resolution health on the 814-file app: 812/814 resolved, 0 errors, 0 unresolved
URIs. Two runs are byte-identical after dropping timing, and `pipe check` output
is byte-identical across runs.
## Tests
```bash
dart test
```
Tests cover resolution/IR extraction, the Firestore query inventory (against
a fixture with a hand-written cloud_firestore skeleton and a gateway adapter),
rule contracts (positive *and*
negative controls per rule), Riverpod and provider modelling, agent-brief
rendering, fix-order scoring, baseline round-tripping (including legacy
fingerprints-only baselines), verification, and recall evaluation, against a
dependency-free fixture project.
- [Usage](docs/usage.md): commands, flags, SDK discovery, CI ratcheting,
verification, fix order, agent briefs.
- [Rules](docs/rules.md): what each rule claims, the evidence behind it, and how
precision and recall are measured.
- [Firestore queries](docs/queries.md): the query inventory, its schema and
adapter config.
- [Design](docs/design.md): why pipe exists, how it is built, the state-model
seam, and the experimental C++ front end.
- [Development](docs/development.md): building, performance, tests and hooks.

98
docs/design.md Normal file
View file

@ -0,0 +1,98 @@
# Design
## Motivation
The state bugs users feel (a spinner that never stops, an error never shown, a
tap that does nothing) are whole-program facts: a state emitted in one file and
never matched in any other, an event dispatched with no handler anywhere. A
per-file linter cannot express them, so pipe resolves the whole project and
reasons over all of it at once.
pipe is a CLI on `package:analyzer`, not a `custom_lint` or analyzer plugin:
plugins have no project-wide index, `flutter analyze` does not report
new-system plugin diagnostics, and a CLI can pin its own analyzer version.
## Architecture
```text
bin/pipe.dart entrypoint
lib/src/cli.dart commands and flags
lib/src/errors.dart PipeException (message + remedy)
lib/src/project.dart package validation, source discovery
lib/src/sdk.dart SDK discovery (Flutter-aware)
lib/src/analysis.dart analysis contexts, per-file resolution, scan
coverage, unresolved references
lib/src/pool.dart bounded-concurrency map over files
lib/src/excerpt.dart bounded, literal-free source excerpts
lib/src/index.dart resolution health, timing, diagnostics
lib/src/model.dart declaration model (`graph`)
lib/src/state_flow.dart Dart front end: the state model and its JSON
lib/src/dart_facts.dart Dart front end: loading and construction facts
lib/src/dart_provider_facts.dart Dart front end: ChangeNotifier / provider facts
lib/src/facts.dart language-neutral facts (JSON round-trip)
lib/src/rules.dart rules, findings, baselines (model only)
lib/src/provider_rules.dart ChangeNotifier / provider rules (model only)
lib/src/pipeline.dart analyse -> model -> rules
lib/src/verify.dart baseline verification
lib/src/suggest.dart fix order
lib/src/guide.dart agent brief, markdown report, yams emission
lib/src/recall.dart labelled-bug recall
lib/src/firestore_facts.dart Firestore query inventory (no rules)
lib/src/gateway_facts.dart adapters for declarative query APIs
tool/adjudicate.py precision adjudicator
tool/cpp_front.py experimental C++ front end
tool/build_release.sh native AOT build
```
Resolution mirrors Flutter's own tooling: the context collection is rebuilt
when `package_config.json` changes, file changes go through `changeFile` and
`applyPendingFileChanges`, and an `InconsistentAnalysisException` gets a fresh
session.
## The state model
Rules never read a syntax tree. A front end turns code into a state model:
unions and variants, where each is emitted and matched, events dispatched and
handled, busy paths whose failure is swallowed, and observable objects with
whether their mutations are announced. `runChecks` takes only that model.
The model is plain JSON, and a model read back from JSON reports exactly what
the in-process one does (`test/state_map_roundtrip_test.dart`):
```bash
pipe state . --format json > model.json # or any front end that writes it
pipe check --model model.json # every rule, baseline and gate
```
A front end for another language only has to write that JSON.
## C++ front end (experimental)
`tool/cpp_front.py` reads a compile database with libclang (Homebrew LLVM) and
maps each project `enum class` to a union:
- **emission**: a reference that produces the value (assignment, atomic
`store`, argument, return, initialiser); a cast from an integer is an
indirect emission, which makes "never emitted" unprovable for that enum;
- **observation**: a `case`, `==` or `!=`;
- **swallowed failure**: a busy enumerator written to the method's own state,
then a top-level `catch` that neither writes state nor rethrows.
Identity comes from clang's resolved AST, so two enums with the same name are
never confused. Sources the build does not compile are recorded as unresolved.
```bash
tool/cpp_front.py <repo> --build-dir <repo>/build/debug -o model.json
pipe check --model model.json
```
For C++, read `rendered-not-emitted` as "branched on but never produced" (a
dead branch, or a state a state machine never enters) and `emitted-not-rendered`
as "produced but never branched on" (computed and ignored, or reaching output
only through a name table or reflection). Rule messages still say "UI".
On yams (375 translation units, about two minutes) a hand check of 13 findings
found 12 factually correct and 1 false positive, which the front end now
excludes. That sample bounds the false-positive rate; it does not measure it.
Controls live in `test/fixtures/cpp_state` and `test/cpp_front_test.dart`,
skipped when LLVM is absent.

56
docs/development.md Normal file
View file

@ -0,0 +1,56 @@
# Development
## Building
```bash
dart pub get
tool/build_release.sh # `dart build cli`, falling back to `dart compile exe`
```
Use the native binary rather than `dart run`: JIT startup is paid on every
invocation. On macOS arm64:
| Scenario | `dart run` | AOT binary |
| --- | --- | --- |
| 2-file project | 6.4s | 0.2s |
| 814-file Flutter app, end to end | ~23-29s | 10.4s |
| per-file p50 on that app | ~50-75ms | 16ms |
The binary is about 18MB and self-contained. On a large project the first file
costs about 3s of analyzer warm-up (SDK and package summaries). Cutting that
would need a persistent analysis cache.
## Tests
```bash
dart test
```
The rule suite runs against `test/fixtures/stuck_loading`, a dependency-free
project with hand-written stand-ins for BLoC, Riverpod and provider, so it
resolves with no `pub get` and no network. Do not add a `package:` import to
it. The query inventory runs against `test/fixtures/firestore_queries`, which
carries a hand-written `cloud_firestore` skeleton and a gateway adapter.
What the suite holds pipe to:
- every rule has a positive and a negative control (`rule_contract_test.dart`);
- no fingerprint depends on position, and none collide (`fingerprint_test.dart`);
- the state model survives JSON unchanged, with identical findings
(`state_map_roundtrip_test.dart`);
- partial, unreadable and unresolved scans are reported, never passed silently.
`test/cpp_front_test.dart` runs the C++ front end on `test/fixtures/cpp_state`
and is skipped without Homebrew LLVM.
## Hooks and CI
`git config core.hooksPath .githooks` enables the hooks. `pre-commit` runs
`scripts/lint.sh --hook` (format and analyze); `pre-push` runs
`scripts/pre-push-ci-gate.sh` (format, analyze and the full test suite).
Commit messages follow Conventional Commits (`commitlint.config.js`).
## Measuring precision and recall
See [rules.md](rules.md#how-precision-is-measured) for `tool/adjudicate.py` and
`pipe recall`.

216
docs/queries.md Normal file
View file

@ -0,0 +1,216 @@
# Firestore query inventory
`pipe queries` lists every Firestore query the scanned code can issue, as
typed, machine-readable records. It is an **inventory only**: no rules, no
findings, no baseline, and deliberately no index analysis. Deciding whether a
query needs a composite index, and probing a live project, belong to the
consumer of this output, which can compare it against `firestore.indexes.json`.
What it recognises:
- **cloud_firestore chains.** Receivers are identified by **static type and
library**: an `InterfaceType` named `FirebaseFirestore`, `CollectionReference`,
`Query`, `DocumentReference`, `AggregateQuery`, `Filter` or `FieldPath` whose
library URI starts with `package:cloud_firestore/` or
`package:cloud_firestore_platform_interface/`. Nothing is matched by name, so
`Iterable.where`/`take`, or a project class called `Query`, never is.
- **One record per terminal call**: `get()` (`realtime: false`), `snapshots()`
(`realtime: true`), `count()` / `aggregate(...)` (`aggregate: count|sum|average`)
on a `Query` or `CollectionReference`. `DocumentReference.get/snapshots` are
point reads that need no index: they are counted in `totals.documentReads`
and never listed.
- `collection(path)`, `collectionGroup(id)` and `doc(...)` segments become
`scope`, `collection` (the last collection id) and a `path` pattern such as
`stores/*/products`. Document ids are expected to be dynamic and cost
nothing; a dynamic **collection** id makes the record `partial`, and a fully
dynamic one also sets `collection: null`.
- `where(...)` with every named operator (`isEqualTo` `==`, `isNotEqualTo` `!=`,
`isLessThan` `<`, `isLessThanOrEqualTo` `<=`, `isGreaterThan` `>`,
`isGreaterThanOrEqualTo` `>=`, `arrayContains` `array-contains`,
`arrayContainsAny` `array-contains-any`, `whereIn` `in`, `whereNotIn`
`not-in`, `isNull: true` `==` null, `isNull: false` `!=` null); a field may be
a string, `FieldPath([...])` (dotted) or `FieldPath.documentId` (`__name__`).
`where(Filter.or(...))` / `Filter.and(...)` become a `composite` node.
- `orderBy(field, descending:)`, `limit`, `limitToLast` (`limit.last: true`),
and any cursor (`startAt`, `startAfter`, `endAt`, `endBefore` and their
`...Document` forms) as `cursor: true`. `withConverter` passes through.
- Strings resolve through literals, adjacent and interpolated strings (`*` for
each unknown part), `+`, `const` and `final` variables with a literal
initializer, and one-return path helpers (`storePath(id) => 'stores/$id'`)
with their arguments bound.
- **Built queries.** A local `Query` variable is the union of its definitions in
its function body; a clause added under an `if`, ternary, `switch`, loop,
`catch` or closure is `conditional: true`. Two cuts keep that union honest:
an assignment textually after the terminal is ignored unless both sit in one
loop, and an `if` that encloses both the assignment and the terminal does not
make the clause conditional. Fields are followed through their initializer,
constructor initializers and assignments in their class.
- **Helpers.** A getter, method or function in the project that returns a query
is summarised and inlined at its call sites with the call's arguments bound.
Methods are inlined **one level deep**; getters and fields take no arguments
and do not count against that. A query that arrives as a parameter, or from a
call pipe cannot follow, is `origin: unknown`, `confidence: unknown`, with
the clauses added locally still recorded.
- `inLoop: true` when the terminal is inside a `for`/`for-in`/`while`/`do` body
or a collection `for`, or inside a closure passed to `Iterable.map`/
`forEach`/`expand`, `Stream.asyncMap`, or within `Future.wait(...)`. A fixed
`Future.wait([a.get(), b.get()])` is not a loop.
## Schema
```json
{
"version": 1,
"root": "relative path, or the directory name when outside the cwd",
"coverage": {"discovered": 827, "selected": 827, "resolved": 825, "failed": 0, "skipped": 2, "complete": false},
"totals": {
"queries": 372,
"byConfidence": {"exact": 336, "partial": 28, "unknown": 8},
"byScope": {"collection": 297, "collectionGroup": 66, "unknown": 9},
"byOrigin": {"chain": 189, "helper": 47, "unknown": 8, "variable": 128},
"realtime": 28, "inLoop": 37, "documentReads": 165,
"collections": {"bookings": 23, "...": 0}
},
"unresolvedFirestoreUris": [{"site": "lib/a.dart:3", "uri": "package:cloud_firestore/cloud_firestore.dart"}],
"notes": ["why the inventory may be incomplete; never empty when queries is"],
"queries": [{
"site": "lib/services/category_service.dart:23",
"owner": "CategoryService", "member": "getCategories",
"scope": "collection", "collection": "categories", "path": "categories",
"filters": [{"field": "isActive", "op": "==", "conditional": false, "valueType": "bool"}],
"orderBy": [{"field": "name", "dir": "asc", "conditional": false}],
"limit": {"kind": "none", "conditional": false},
"cursor": false, "realtime": false, "inLoop": false,
"confidence": "exact", "origin": "chain", "reasons": []
}]
}
```
- `filters[].valueType`: `string`, `int`, `double`, `num`, `bool`, `timestamp`
(`DateTime` or `Timestamp`), `null`, `list`, `map`, `reference`, or
`unknown`; `inCount` is the length of a literal list operand.
- `composite` (optional): `{op: or|and, filters: [filter | {composite}]}`.
- `limit`: `{kind: literal|variable|none, value?, conditional, last?}`.
- `orderBy[].dir`: `asc`, `desc`, or `unknown` when `descending:` is not a
constant.
- `confidence`: `exact`, `partial` (the source is known but something is not:
every cause is in `reasons`), or `unknown` (the query's source could not be
followed). `origin`: `chain`, `variable`, `helper`, `unknown`, `adapter`.
- `queries` is sorted by site. Status lines and notes go to stderr, so stdout
is exactly the document. `--format md` renders the same data grouped by
collection, with the coverage line first.
**Injection policy.** The analysed repository is untrusted (see
[agent briefs](usage.md#agent-briefs)). The inventory never quotes source: every
collection, path and field name must match `^[A-Za-z0-9_.*/\-]+$` or it is
replaced with `*` and a reason is added; owners and members are identifiers.
**Unresolved packages.** If `cloud_firestore` is not in the package graph, or
an import of it does not resolve, the output says so in `notes` (and
`unresolvedFirestoreUris`) rather than presenting an empty list as a clean
result; `--require-firestore` turns that into exit 2.
## Adapter config
Server-side code often wraps Firestore in a declarative API that takes a query
as data, so there is no chain to follow. `--adapter-config` (YAML, or JSON,
which is a subset of YAML) tells pipe what such an API's arguments mean. The
resulting records use the same schema with `origin: adapter`. For a gateway
like this:
```dart
abstract class FirestoreGateway {
Future<List<QueriedDoc>> queryCollection(String collectionPath,
{List<WhereEquals> filters = const [], WhereArrayContains? arrayContains,
List<WhereRange> ranges = const [], List<OrderBy> orderBy = const [],
String? startAfterId, int? limit});
Future<List<QueriedDoc>> queryCollectionGroup(String collectionId,
{List<WhereEquals> filters = const [], WhereIn? inFilter,
WhereArrayContainsAny? arrayContainsAny, List<WhereRange> ranges = const [],
List<OrderBy> orderBy = const [], int? limit});
}
abstract class FirestoreTransaction {
Future<List<QueriedDoc>> queryCollection(String collectionPath,
{List<WhereEquals> filters = const [], int? limit});
}
// WhereEquals(field, value), WhereIn(field, values), WhereArrayContains(field, value),
// WhereArrayContainsAny(field, values), WhereRange(field, {gte, gt, lte, lt}),
// OrderBy(field, {descending = false})
```
the config is:
```json
{
"adapters": [{
"interface": ["FirestoreGateway", "FirestoreTransaction"],
"library": "package:nuwayze_functions_dart/src/data/firestore_gateway.dart",
"methods": {
"queryCollection": {"scope": "collection", "path": 0},
"queryCollectionGroup": {"scope": "collectionGroup", "path": 0}
},
"args": {
"filters": "filters", "arrayContains": "filters", "arrayContainsAny": "filters",
"inFilter": "filters", "ranges": "filters",
"orderBy": "orderBy", "limit": "limit", "startAfterId": "cursor"
},
"types": {
"WhereEquals": {"field": 0, "op": "==", "value": 1},
"WhereIn": {"field": 0, "op": "in", "value": 1},
"WhereArrayContains": {"field": 0, "op": "array-contains", "value": 1},
"WhereArrayContainsAny": {"field": 0, "op": "array-contains-any", "value": 1},
"WhereRange": {"field": 0, "ops": {"gte": ">=", "gt": ">", "lte": "<=", "lt": "<"}},
"OrderBy": {"kind": "orderBy", "field": 0, "descending": "descending"}
}
}]
}
```
- `interface` (a name or a list) + `library`: a call matches when the invoked
method's enclosing class is one of these interfaces from that library, or a
class implementing one. Names alone never match.
- `methods.<name>`: `scope` (`collection` | `collectionGroup`), `path` (the
argument holding the path or group id), optional `realtime: true`.
- `args.<named argument>`: a role - `filters` and `orderBy` (a value object, a
list literal with `if`/`for`/spread elements, a ternary, or a local list built
with `add`/`addAll`; elements under an `if` are conditional), `limit`, or
`cursor` (any non-null value).
- `types.<class>`: how to read one value object, matched by class name and
library (the adapter's, or `library:`). `field` and `value` are a positional
index or a named argument. The operator is either fixed (`op`), one per
present named bound (`ops`), or read from an argument (`opArg`) and mapped
through `opMap`, keyed by enum constant name or string value
(`{"opArg": 1, "opMap": {"lessThan": "<", "greaterThan": ">"}}`).
`kind: orderBy` with `descending` reads an ordering instead of a filter.
- Every operator must be one of `==`, `!=`, `<`, `<=`, `>`, `>=`,
`array-contains`, `array-contains-any`, `in`, `not-in`; a config that names
anything else, an unknown role, or a bad scope is refused with exit 2.
## Limits
- Flow-insensitive within a function, apart from the two cuts above: a clause
on a path that returns early is still in the union (as conditional).
- Helper methods are inlined one level deep. A query-consuming wrapper - a
method that takes a `Query` parameter and calls `get()` itself - is reported
at its own terminal as `unknown`; the clauses its callers pass in are not
attributed to it.
- A collection name taken from a loop variable over a constant list, a map
lookup or a runtime value is `*`, with the record `partial`.
- No index analysis, by design: the output carries what an index check needs
(scope, collection group, equality vs. range vs. array operators, order
directions, `in` sizes) and leaves the decision to the consumer.
## Calibration
Calibrated on the NuWayze app (Flutter, `lib/`, 827 files) and its Dart backend
(`functions-dart`, 147 files, with the config above), 2026-09-23:
| package | extracted | independent count | exact / partial / unknown |
| --- | --- | --- | --- |
| app | 372 query terminals + 165 document reads | 372 `get`/`snapshots`/`count`/`aggregate` calls whose receiver resolves to `Query`/`CollectionReference` (336 + 36) | 336 / 28 / 8 |
| backend | 136 adapter calls | 136 `queryCollection(`/`queryCollectionGroup(` call sites by grep | 121 / 15 / 0 |
Every `partial` is a genuinely dynamic collection or field name; the 8
`unknown` are query-consuming wrappers (a `Query` parameter, or a wrapper that
applies caller-supplied builder closures). Zero false positives were found:
records exist only where the receiver type resolves into cloud_firestore.

83
docs/rules.md Normal file
View file

@ -0,0 +1,83 @@
# Rules
Every rule states a factual, whole-program claim ("no UI code matches this
state"), scoped to the code that was scanned. Evidence is `path:line:` plus a
bounded source excerpt with string literals elided. Precision below is
**factual** precision: how often the claim holds, not how often it is worth
acting on. A rule that cannot be validated is demoted or removed.
## Evidence per rule
Measured on the dogfood apps (nuwayze, dex, word_alive, tick).
| Rule | Evidence |
| --- | --- |
| `emitted-not-rendered` | 79/79 claims factual on nuwayze main (adjudicator with passing controls). One finding per unobserved union and per unobserved marker class. Drops to info when the UI reads the union's payload through base-class fields. |
| `rendered-not-emitted` | Fixture contract; suppressed with a reason when the owner emits through helper methods. Worded as "probably unreachable": only the scanned code is known. |
| `loading-not-reset` | Fixture contract; the dogfood apps guard correctly. |
| `loading-flag-not-reset` | 7/7 on dex, hand-adjudicated. |
| `event-without-handler` | Fixture contract; 168 dispatch sites on nuwayze all map to a handler. Follows the event hierarchy (`on<Base>` covers subtypes) and blocs sharing an event type. |
| `dead-handler` | 77/77 on nuwayze (adjudicator). The message names the directories scanned; events built only in tests are not features. |
| `riverpod-async-gap` | 10 hand-adjudicated findings (word_alive, tick). Recognises `.when`, `AsyncError`/`AsyncLoading` patterns and values stored in locals. `bool` providers are downgraded to info. |
| `notifier-never-notifies` | Fixture contract only; no real-code true positives yet. Limited to observable fields; lifecycle hooks excluded. |
| `notifier-inert-consumer` | Fixture contract only. |
| `provider-read-in-build` | 1 finding from 447 `context.read` calls on nuwayze. Reads inside closures are excluded by design. |
| `notifier-field-unread` | 3 on nuwayze; medium confidence, since a name match elsewhere can mask an unread member. |
| `provider-created-in-build` | Info, once per build method: the claim is factual, but impact depends on how often the widget rebuilds. |
## A factual finding is not always a bug
`emitted-not-rendered` at high confidence can be deliberate: dormant scaffolding
or a retired feature. Those findings are one decision per union, and the
remediation asks to check the repo's docs and action contracts before deleting
anything.
## How precision is measured
`tool/adjudicate.py <repo>` re-derives the two highest-volume claims
(`emitted-not-rendered`, `dead-handler`) with its own regex evidence, and prints
controls first: it must find evidence for every variant pipe calls rendered and
every event pipe calls dispatched. Controls are three-way (found /
unattributable / missing), and only *missing* fails. A corpus with no control
material reports **NO SIGNAL** and undefined precision. The script exits
non-zero whenever it cannot certify.
Limits: it is only partly independent (the list of variants, events and owners
to check comes from pipe's `state` output), it judges the factual claim only,
not severity, and it needs a clean working tree.
## Recall
1. **Rule contracts.** `test/rule_contract_test.dart` requires each rule to fire
on a positive control and stay silent on an adjacent negative control. A rule
without both fails the build.
2. **Labelled real bugs.** `pipe recall` evaluates `recall/manifest.json`. An
`open` case must be found; a `fixed` case must stay quiet, and reappearing is
a **REGRESSION**. Repos missing from the machine are *skipped*, never passed.
Results reflect each repo's working tree.
The labelled set is small: it bounds the risk of false negatives, it does not
prove their absence.
## provider and ChangeNotifier
Modelled: every provider-package declaration type (`Provider`,
`ChangeNotifierProvider`, `ListenableProvider`, `ValueListenableProvider`,
`FutureProvider`, `StreamProvider`, `StateProvider`, `ProxyProvider` and
variants, `.value`), and `Consumer`, `Selector`, `ListenableBuilder` and
`AnimatedBuilder` as consumers. Declarations are told apart from Riverpod by
`create:`/`update:`/`value:` arguments, not by import, so an unrelated class
named `Provider` taking `create:` would be miscounted.
Not modelled: `ProxyProvider` `update` functions returning unchanged values, and
rebuild frequency.
## Other limits
Not modelled: `bloc_concurrency` transformers, freezed unions whose generated
file is absent, and `ref.read` call sites.
pipe deliberately does not reimplement per-file checks the ecosystem already
does well: `unawaited_futures`, `use_build_context_synchronously`,
`cancel_subscriptions`, `close_sinks`, `bloc_lint`, or declaration-level dead
code (`undead`, `dallow`).

166
docs/usage.md Normal file
View file

@ -0,0 +1,166 @@
# Usage
## Commands
| Command | What it does |
| --- | --- |
| `pipe index <path>` | Resolve the project and report analysis health, timing and diagnostics. `--fail-on-error` exits 1 on analyzer errors. |
| `pipe graph <path>` | The resolved declaration model, as JSON. |
| `pipe state <path>` | The state model: unions, emissions, render sites, events, Riverpod, notifier facts. `--format json` writes the model other commands can read back. |
| `pipe check <path>` | Ranked findings with evidence and remediation. |
| `pipe suggest <path>` | Findings collapsed to root causes and ranked, globally and per file. |
| `pipe guide <path>` | A prompt-sized brief for an agent; `--yams` persists it to the yams corpus. |
| `pipe verify <path> --baseline F` | Which accepted findings were resolved, remain, or are new. |
| `pipe recall --root <dir>` | How many labelled known bugs pipe detects (see [rules](rules.md#recall)). |
| `pipe queries <path>` | Every Firestore query the code can issue, as typed records; an inventory, no rules (see [queries](queries.md)). |
Common flags: `--format text|json` (`agent|md|json` for `guide`,
`text|agent|json` for `suggest`), `-c/--concurrency N` (default 4; 8 for
`recall`), `--sdk <path>`, `--dir <dir>` (repeatable, default `lib`; a missing
directory is an error), `--[no-]include-generated` (default on: `*.g.dart`,
`*.freezed.dart` and `*.mocks.dart` are scanned), `--rule <name>` (repeatable),
`--top N`, and `--limit N` (debugging only; results are marked partial).
Command-specific: `check --fail-on`, `--baseline`, `--write-baseline`,
`--model`; `guide --budget --max-findings --yams --yams-name` (`--yams` persists
the full markdown report, tagged with the analysed commit and a timestamp);
`suggest --budget --max-actions`; `verify --expect-resolved`;
`recall --manifest --root --strict` (without `--manifest`, `recall/manifest.json`
in the working directory, else the one in the pipe checkout);
`queries --format json|md --adapter-config <file> --require-firestore`;
`--policy wire-up|remove` on `check`, `guide` and `suggest`.
Every numeric flag rejects a value that is not a positive integer instead of
falling back to its default.
## Output contract
- **stdout carries only the requested output.** Status lines (`wrote baseline
to ...`, `persisted to yams as ...`) and warnings go to stderr, so
`--format json` always parses.
- **Every report states its coverage.** `check`, `guide` and `suggest` JSON carry
`coverage: {discovered, selected, resolved, failed, skipped, complete}`, and
the text outputs, brief and plan open with a coverage line that says PARTIAL
whenever a discovered file was not analysed. `skipped` counts files the
analyzer does not cover (excluded by `analysis_options.yaml`, or outside any
analysis context). A state model carries the same block as `scanCoverage`, so
`check --model` reports it too.
- **Exit codes:** 0 success; 1 a gate failed (`check --fail-on`,
`verify --expect-resolved`, `recall --strict`, `index --fail-on-error`, or
`guide --yams` could not persist); 2 pipe could not do what was asked (a bad
path or `--dir`, an unreadable project, a baseline that cannot be read or
written, or an internal error: rerun with `PIPE_DEBUG=1` for its stack trace);
64 invalid usage, including more than one project path.
## SDK and resolution
pipe finds the SDK itself: `flutterRoot` from the target's
`.dart_tool/package_config.json`, then `FLUTTER_ROOT`, then `flutter` on `PATH`.
Flutter projects need Flutter's bundled Dart SDK, because `dart:ui` is not part
of the standalone one. Override with `--sdk`.
pipe refuses to report a clean result it did not earn:
- A project never resolved (`pub get` not run) is an error, not a scan in which
every `package:` import is `dynamic`.
- An SDK that exists but cannot be read (for example under a privacy-protected
macOS folder) is refused with a `--sdk` remedy.
- Files that fail to resolve make the results **PARTIAL**, with a warning.
- Files with unresolved references (`uri_does_not_exist`, `undefined_*`) produce
a warning and a report note, and every finding naming something such a file
mentions drops one confidence step (`data.confidenceLowered`). An unresolved
`Foo()` is a method call, not a construction, so evidence there is invisible.
## CI and baselines
```bash
pipe check . --write-baseline .pipe-baseline.json
pipe check . --baseline .pipe-baseline.json --fail-on warning
```
`--fail-on none|info|warning|error` (default `none`) fails when a **new** finding
at or above that severity is present. Baselined findings never trip it.
`--fail-on-finding` is an alias for `--fail-on error`.
A baseline stores one fingerprint per finding: rule, file and semantic identity
(union, variant, event, provider, owner, member; for two swallowed catches in one
method, the catch's index within it). Fingerprints never include a line number,
so moving code does not resurrect accepted findings. Entries carry a readable
label, so `verify` names what was resolved, and the project root is recorded
relative to the baseline file.
Baselines are written as `"version": 2`. Version-1 baselines, whose fingerprints
embedded a line number for four rules, are still matched, but those entries stay
line-sensitive until the baseline is rewritten with `--write-baseline`.
`--write-baseline` refuses a partial scan (`--rule`, `--limit`, or unreadable
files), because the baseline would omit every finding outside it. `check --model`
refuses it too, since a model does not record whether every file was read.
## Verifying a fix
```text
$ pipe verify . --baseline .pipe-baseline.json
baseline 12 accepted findings
resolved 1 no longer reported — these fixes are confirmed
still 11 still reported
new 0 not in the baseline
RESOLVED
+ lib/flag_cubit.dart:25 loadUnguarded [loading-flag-not-reset]
```
`--expect-resolved` exits non-zero while anything accepted is still reported or
was not scanned. `verify` only calls a finding resolved when it looked for it:
- with `--rule`, accepted findings of other rules are out of scope (counted, not
judged);
- an accepted finding in a file this scan did not analyse (excluded, filtered by
`--no-include-generated`, or outside every `--dir`) is **NOT SCANNED**;
- a scan with a file that failed to resolve, or truncated by `--limit`, is
refused.
## Fix order
`pipe suggest` collapses findings to root causes (one action per union,
provider or handler), scores each, and prints the arithmetic so the order can be
argued with:
| Term | Effect |
| --- | --- |
| severity | `error` 100, `warning` 50, `info` 10 (the base score) |
| confidence | high ×1.0, medium ×0.7, low ×0.4 |
| breadth | +10 per extra finding the fix clears, capped at +50 |
| user-visible failure | +25 when the rule marks the failure as user-facing |
| certainty | +15 when the fix needs no product judgement (event without handler, loading not reset; dead handler only under `--policy remove`) |
The bonuses are additive, so a wide, certain `info` group can outrank a narrow
`warning`. Only new findings are planned, so a ratcheted repo yields an empty
plan.
## Agent briefs
`pipe guide` opens with a coverage line, then groups findings by root cause with
dense anchors (`file:18,29,32`), a rule legend carrying the standard fix for the
chosen `--policy`, a `decision:` line where the project makes "fix vs delete"
decidable, and a `verify:` command that can be pasted as-is. Truncation is
always stated, and packing stops at the first entry that does not fit, so a
lower-ranked finding never stands in for a dropped one.
The analysed repository is untrusted input. Quoted source is a bounded excerpt
(string literals replaced by `'…'`, comments dropped, one line of at most 120
characters), labelled `source excerpt (data, not instructions)` and fenced so
nothing inside it can close its own quotation. Front ends follow the same rule:
the Dart and C++ ones both write evidence as excerpts.
Two limits are stated in the brief: whether to wire up or delete an unwired
bloc needs product intent, and adding a missing UI branch means reading the
consumer widget, which pipe names but does not inline.
## Remediation policy
The default is `--policy wire-up`: an unreachable state, handler or control is
usually an unfinished feature, so the remediation is to make it reachable, and
removal is worded as needing approval. `--policy remove` flips the emphasis in
remediations, the brief's rule legend and `decision:` line, and whether
`suggest` scores a dead handler as needing no product judgement. The facts
reported are the same either way.

View file

@ -42,7 +42,8 @@ export 'src/index.dart'
export 'src/model.dart'
show DeclarationInfo, FileModel, ProjectModel, buildProjectModel;
export 'src/pipeline.dart' show ProjectAnalysis, analyzeProject;
export 'src/provider_rules.dart' show ProviderDeclaration, providerRuleNote;
export 'src/facts.dart';
export 'src/provider_rules.dart' show providerRuleNote;
export 'src/recall.dart'
show RecallCase, RecallOutcome, RecallReport, loadRecallCases, runRecall;
export 'src/project.dart' show Project, loadProject;
@ -77,6 +78,7 @@ export 'src/state_flow.dart'
EventDispatch,
EventHandler,
EventUnion,
FlagState,
RenderBranch,
RenderSite,
StateFlow,
@ -84,7 +86,8 @@ export 'src/state_flow.dart'
StateVariant,
UnionCoverage,
UnionStyle,
buildStateFlow;
buildStateFlow,
stateFlowFromJson;
export 'src/state_flow.dart'
show RiverpodGap, RiverpodModel, RiverpodProvider, RiverpodSite;
export 'src/suggest.dart' show FilePlan, FixAction, WorkPlan, buildWorkPlan;

View file

@ -4,6 +4,8 @@ import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/analysis/session.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/diagnostic/diagnostic.dart';
import 'package:analyzer/file_system/file_system.dart' as fs;
import 'package:analyzer/file_system/physical_file_system.dart';
@ -37,6 +39,100 @@ class FileOutcome {
List<Diagnostic> get diagnostics => unit?.diagnostics ?? const <Diagnostic>[];
}
/// Diagnostic codes meaning a name or import did not resolve. Unlike a type
/// error, these change the syntax tree pipe reads: an unresolved `Foo()` is a
/// method call rather than a construction, so a dispatch or emission of `Foo`
/// disappears from the analysis.
const Set<String> unresolvedReferenceCodes = {
'uri_does_not_exist',
'undefined_class',
'undefined_identifier',
'undefined_method',
'undefined_function',
'undefined_getter',
'undefined_setter',
'undefined_super_member',
'undefined_prefixed_name',
'creation_with_non_type',
'extends_non_class',
'implements_non_class',
'mixin_of_non_class',
'non_type_as_type_argument',
};
/// Resolved files that contain at least one unresolved reference.
class UnresolvedReferences {
UnresolvedReferences({
required this.files,
required this.analysed,
this.first,
this.names = const {},
});
/// Every identifier those files mention. pipe's evidence is name-based, so an
/// unresolved file can only hide evidence about a name it mentions.
final Set<String> names;
/// Paths (absolute) of resolved files with an unresolved reference.
final List<String> files;
/// How many files resolved at all.
final int analysed;
/// The first such diagnostic as `path:line: message`, for the reader.
final String? first;
bool get any => files.isNotEmpty;
}
UnresolvedReferences findUnresolvedReferences(List<FileOutcome> outcomes) {
final files = <String>[];
final names = <String>{};
String? first;
var analysed = 0;
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
analysed++;
for (final diagnostic in unit.diagnostics) {
if (!unresolvedReferenceCodes.contains(
diagnostic.diagnosticCode.lowerCaseName,
)) {
continue;
}
files.add(outcome.path);
final line = unit.lineInfo.getLocation(diagnostic.offset).lineNumber;
first ??= '${outcome.path}:$line: ${diagnostic.message}';
final collector = _Names();
unit.unit.accept(collector);
names.addAll(collector.names);
break;
}
}
return UnresolvedReferences(
files: files,
analysed: analysed,
first: first,
names: names,
);
}
class _Names extends RecursiveAstVisitor<void> {
final Set<String> names = {};
@override
void visitSimpleIdentifier(SimpleIdentifier node) {
names.add(node.name);
super.visitSimpleIdentifier(node);
}
@override
void visitNamedType(NamedType node) {
names.add(node.name.lexeme);
super.visitNamedType(node);
}
}
/// How much of a project an analysis actually examined.
///
/// Every report carries this, because a finding count means nothing without it:
@ -51,6 +147,14 @@ class ScanCoverage {
required this.skipped,
});
factory ScanCoverage.fromJson(Map<String, Object?> json) => ScanCoverage(
discovered: json['discovered']! as int,
selected: json['selected']! as int,
resolved: json['resolved']! as int,
failed: json['failed']! as int,
skipped: json['skipped']! as int,
);
factory ScanCoverage.of({
required int discovered,
required List<FileOutcome> outcomes,
@ -129,6 +233,7 @@ class Analyzer {
explicit: sdkOverride,
flutterRootHint: project.flutterRoot,
);
requireReadableSdk(resolution);
final AnalysisContextCollection collection;
try {
collection = AnalysisContextCollection(

View file

@ -122,9 +122,43 @@ Set<String>? _readBaseline(ArgResults results) {
return decodeBaseline(_readText(path, 'baseline'));
}
void _writeBaseline(ArgResults results, String root, List<Finding> findings) {
/// A baseline is the set of findings a repo accepts, so it must come from a
/// complete scan with every rule on. Written from a subset, every finding
/// outside that subset is missing from it, and the next full `check --baseline`
/// reports all of them as new. Refused before analysis so the flags fail fast.
void _refuseSubsetBaseline(ArgResults results) {
if (results['write-baseline'] == null) return;
final narrowing = [
if ((results['rule'] as List<String>).isNotEmpty) '--rule',
if (results['limit'] != null) '--limit',
];
if (narrowing.isEmpty) return;
throw PipeException(
'--write-baseline needs a complete scan: with ${narrowing.join(' and ')} '
'the baseline would omit every finding outside that subset',
remedy: 'drop ${narrowing.join(' and ')} when writing a baseline',
);
}
void _writeBaseline(
ArgResults results,
ProjectAnalysis analysis,
List<Finding> findings,
) {
final path = results['write-baseline'] as String?;
if (path == null) return;
// Findings in a file that could not be analysed are absent, not fixed.
final unresolved = analysis.run.outcomes
.where((outcome) => !outcome.resolved && !outcome.skipped)
.length;
if (unresolved > 0) {
throw PipeException(
'--write-baseline needs a complete scan: $unresolved file(s) could not '
'be analysed, so their findings would be missing from the baseline',
remedy: 'run `pipe index` to see why they failed, then write it again',
);
}
final root = analysis.project.root;
try {
// Record the project root relative to the baseline file, so a committed
// baseline does not carry one machine's absolute path (and does not churn
@ -413,6 +447,12 @@ class CheckCommand extends Command<int> {
..addOption(
'write-baseline',
help: 'Write the current findings to this baseline file.',
)
..addOption(
'model',
help:
'Check a state model (`pipe state --format json`, or any front end '
'that writes it) instead of analysing Dart source.',
);
}
@ -426,12 +466,59 @@ class CheckCommand extends Command<int> {
@override
Future<int> run() async {
final results = argResults!;
_refuseSubsetBaseline(results);
final selected = _readRules(results);
final baseline = _readBaseline(results);
final model = results['model'] as String?;
if (model != null) {
// A model does not record whether its front end read every file, so a
// baseline written from it could silently omit findings.
if (results['write-baseline'] != null) {
throw PipeException(
'--write-baseline cannot be used with --model: a model does not '
'record whether every file was read, so the baseline could be '
'partial',
remedy: 'write the baseline from a source analysis instead',
);
}
final file = File(model);
if (!file.existsSync()) {
throw PipeException('model file not found: $model');
}
final Object? json;
try {
json = jsonDecode(file.readAsStringSync());
} on FormatException catch (error) {
throw PipeException('model is not JSON: ${error.message}');
}
if (json is! Map<String, Object?>) {
throw PipeException('model must be a JSON object: $model');
}
final StateFlow flow;
try {
flow = stateFlowFromJson(json);
} on TypeError catch (error) {
throw PipeException(
'model is missing a field or has one of the wrong type: $error',
remedy:
'write it with `pipe state --format json`, or match that shape',
);
}
final report = runChecks(
flow: flow,
coverage: flow.coverage,
enabledRules: selected.isEmpty ? null : selected,
baseline: baseline,
policy: _readPolicy(results),
);
return _emitCheck(results, report);
}
final project = await loadProject(
_target(results),
directories: results['dir'] as List<String>,
);
final selected = _readRules(results);
final baseline = _readBaseline(results);
final analysis = await analyzeProject(
project,
sdkOverride: results['sdk'] as String?,
@ -443,14 +530,23 @@ class CheckCommand extends Command<int> {
policy: _readPolicy(results),
);
try {
final report = analysis.report;
final code = _emitCheck(results, analysis.report);
_writeBaseline(results, analysis, analysis.report.findings);
return code;
} finally {
await analysis.dispose();
}
}
}
/// Print a check report and apply the --fail-on gate.
int _emitCheck(ArgResults results, FindingReport report) {
final top = readPositiveInt(results, 'top', fallback: 40);
if (results['format'] as String == 'json') {
stdout.writeln(_encode(report.toJson()));
} else {
final top = readPositiveInt(results, 'top', fallback: 40);
stdout.write(report.describe(top: top));
}
_writeBaseline(results, project.root, report.findings);
final threshold = switch (results['fail-on'] as String) {
'error' => PipeSeverity.error,
'warning' => PipeSeverity.warning,
@ -462,12 +558,8 @@ class CheckCommand extends Command<int> {
report.hasNewAtOrAbove(PipeSeverity.error)) {
return 1;
}
} finally {
await analysis.dispose();
}
return 0;
}
}
/// Measure detection against a labelled set of known bugs.
class RecallCommand extends Command<int> {
@ -776,6 +868,7 @@ class GuideCommand extends Command<int> {
@override
Future<int> run() async {
final results = argResults!;
_refuseSubsetBaseline(results);
final project = await loadProject(
_target(results),
directories: results['dir'] as List<String>,
@ -838,13 +931,13 @@ class GuideCommand extends Command<int> {
if (error != null) {
// Write the baseline anyway: a yams outage must not also lose the
// accepted findings the caller asked to record.
_writeBaseline(results, project.root, analysis.report.findings);
_writeBaseline(results, analysis, analysis.report.findings);
stderr.writeln('pipe: could not persist to yams: $error');
return 1;
}
stderr.writeln('persisted to yams as $name');
}
_writeBaseline(results, project.root, analysis.report.findings);
_writeBaseline(results, analysis, analysis.report.findings);
} finally {
await analysis.dispose();
}

453
lib/src/dart_facts.dart Normal file
View file

@ -0,0 +1,453 @@
/// The Dart front end's pattern recognisers for the loading rules.
///
/// These walk syntax trees and emit the language-neutral facts in facts.dart;
/// the rules in rules.dart judge the facts and never see a tree.
library;
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';
import 'excerpt.dart';
import 'facts.dart';
import 'state_flow.dart';
/// Type names constructed in a unit, so a handler is not called dead when its
/// event is built somewhere and dispatched through a variable.
class ConstructionCollector extends RecursiveAstVisitor<void> {
final Set<String> constructed = {};
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
constructed.add(node.constructorName.type.name.lexeme);
super.visitInstanceCreationExpression(node);
}
}
class _TryCollector extends RecursiveAstVisitor<void> {
final List<TryStatement> tries = [];
@override
void visitTryStatement(TryStatement node) {
tries.add(node);
super.visitTryStatement(node);
}
// Nested functions are analysed on their own.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
class _CalledNames extends RecursiveAstVisitor<void> {
final Set<String> names = {};
@override
void visitMethodInvocation(MethodInvocation node) {
final target = node.target;
if (target == null || target is ThisExpression) {
names.add(node.methodName.name);
}
super.visitMethodInvocation(node);
}
@override
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
final function = node.function;
if (function is SimpleIdentifier) names.add(function.name);
super.visitFunctionExpressionInvocation(node);
}
}
/// `on<E>` for a bloc handler closure, so two handlers in one class get
/// distinct identities. As a bare `<closure>` they shared a fingerprint, and
/// the baseline dropped all but one of them.
String _closureMemberName(FunctionExpression node) {
final list = node.parent;
final call = list?.parent;
if (list is ArgumentList && call is MethodInvocation) {
final types = call.typeArguments?.arguments;
if (call.methodName.name == 'on' && types != null && types.length == 1) {
return 'on<${types.single.toSource()}>';
}
}
return '<closure>';
}
/// Finds awaited work on a loading-flag path that no catch clause resolves.
class UnsettledFlagCollector extends RecursiveAstVisitor<void> {
UnsettledFlagCollector(
this.relativePath,
this.lineInfo,
this.flagStatesByOwner,
this.facts,
);
final String relativePath;
final LineInfo lineInfo;
final Map<String, FlagState> flagStatesByOwner;
final List<UnsettledFlag> facts;
final List<String> _classStack = [];
/// Methods invoked from a constructor body: for those, a default-true loading
/// flag is in effect even though the method never sets it itself.
final Map<String, Set<String>> _constructorCalls = {};
static final RegExp _stateAssign = RegExp(r'(^|[^.\w])state\s*=(?!=)');
static final RegExp _throwing = RegExp(r'\b(rethrow|throw)\b');
static final RegExp _errorish = RegExp(
r'(Error|Failure|Fail|Cancel|Retry|Recover|Reset|Clear)',
caseSensitive: false,
);
static bool _surfacesFailure(String source) {
if (source.contains('emit(') || source.contains('notifyListeners(')) {
return true;
}
if (_stateAssign.hasMatch(source)) return true;
if (_throwing.hasMatch(source)) return true;
return source.contains('add(') && _errorish.hasMatch(source);
}
@override
void visitClassDeclaration(ClassDeclaration node) {
final name = node.namePart.typeName.lexeme;
final calls = <String>{};
for (final member in node.body.members) {
if (member is! ConstructorDeclaration) continue;
final collector = _CalledNames();
member.body.accept(collector);
calls.addAll(collector.names);
}
if (calls.isNotEmpty) _constructorCalls[name] = calls;
_classStack.add(name);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMixinDeclaration(MixinDeclaration node) {
_classStack.add(node.name.lexeme);
super.visitMixinDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
_analyze(node.body, node.name.lexeme);
super.visitMethodDeclaration(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
_analyze(node.body, _closureMemberName(node));
super.visitFunctionExpression(node);
}
void _analyze(FunctionBody? body, String member) {
if (body == null) return;
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
final flag = flagStatesByOwner[owner];
if (flag == null) return;
// Lifecycle teardown never renders a loading state.
if (member == 'close' || member == 'dispose') return;
// Only methods that actually put the loading flag into the true state can
// leave the UI spinning. A method that merely runs in a class whose state
// has such a flag (a repository write, a teardown call) cannot.
//
// Every probe here stops at nested functions, which are analysed on their
// own. The flag probe and the await probe used to read closure bodies while
// the try collector skipped them, so a method whose closure set the flag
// and guarded its own await was reported at high confidence.
final flagProbe = _SetsFlagTrue(flag.fields.toSet());
body.accept(flagProbe);
final setsFlagTrue = flagProbe.found;
final inheritsInitialFlag =
flag.defaultsTrue &&
(_constructorCalls[owner]?.contains(member) ?? false);
if (!setsFlagTrue && !inheritsInitialFlag) return;
// Guarded when an awaited try has a catch that surfaces the failure.
final tries = _TryCollector();
body.accept(tries);
var awaitedTryGuarded = false;
var awaitedTrySeen = false;
for (final statement in tries.tries) {
final awaitedInTry = _HasAwait(shallow: true);
statement.body.accept(awaitedInTry);
if (!awaitedInTry.found) continue;
awaitedTrySeen = true;
for (final catchClause in statement.catchClauses) {
if (_surfacesFailure(catchClause.body.toSource())) {
awaitedTryGuarded = true;
}
}
}
if (awaitedTryGuarded) return;
// Nothing awaited at all means there is no failure path to leave dangling.
final awaitProbe = _HasAwait(shallow: true);
body.accept(awaitProbe);
if (!awaitProbe.found) return;
facts.add(
UnsettledFlag(
owner: owner,
member: member,
path: relativePath,
line: lineInfo.getLocation(body.offset).lineNumber,
stateType: flag.name,
fields: flag.fields,
defaultsTrue: flag.defaultsTrue,
setsFlag: setsFlagTrue,
guardedTrySeen: awaitedTrySeen,
),
);
}
}
/// Scans a single function body for in-flight loading emits and top-level
/// awaited try statements.
class _BodyScan extends RecursiveAstVisitor<void> {
final List<int> loadingEmits = [];
final List<TryStatement> topLevelTries = [];
static final RegExp _loading = RegExp(
r'(Loading|Processing|Submitting|Saving|Creating|Updating|InProgress)',
);
int _tryDepth = 0;
void _maybeLoading(AstNode node, List<Argument> arguments) {
if (arguments.isEmpty) return;
final first = arguments.first;
if (first is! Expression) return;
if (_loading.hasMatch(first.toSource())) loadingEmits.add(node.offset);
}
@override
void visitMethodInvocation(MethodInvocation node) {
if (node.methodName.name == 'emit') {
_maybeLoading(node, node.argumentList.arguments);
}
super.visitMethodInvocation(node);
}
@override
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
final function = node.function;
if (function is SimpleIdentifier && function.name == 'emit') {
_maybeLoading(node, node.argumentList.arguments);
}
super.visitFunctionExpressionInvocation(node);
}
@override
void visitTryStatement(TryStatement node) {
final isTopLevel = _tryDepth == 0;
_tryDepth++;
if (isTopLevel) topLevelTries.add(node);
super.visitTryStatement(node);
_tryDepth--;
}
// Nested functions are analysed in their own right by _analyze; descending
// into them here would report the same catch twice.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
class _HasAwait extends RecursiveAstVisitor<void> {
_HasAwait({this.shallow = false});
/// Stop at nested functions: an await inside a closure is not awaited by the
/// body that declares the closure.
final bool shallow;
bool found = false;
@override
void visitAwaitExpression(AwaitExpression node) {
found = true;
super.visitAwaitExpression(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
if (!shallow) super.visitFunctionExpression(node);
}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {
if (!shallow) super.visitFunctionDeclaration(node);
}
}
/// A `field: true` named argument for one of the loading fields, outside any
/// nested function.
class _SetsFlagTrue extends RecursiveAstVisitor<void> {
_SetsFlagTrue(this.fields);
final Set<String> fields;
bool found = false;
@override
void visitNamedArgument(NamedArgument node) {
final value = node.argumentExpression;
if (fields.contains(node.name.lexeme) &&
value is BooleanLiteral &&
value.value) {
found = true;
}
super.visitNamedArgument(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
/// Finds members that emit an in-progress variant and then await work whose
/// catch writes no state. Whether the owner holds rendered state is the rule's
/// judgement, not the front end's, so every owner is scanned.
class SwallowedFailureCollector extends RecursiveAstVisitor<void> {
SwallowedFailureCollector(this.relativePath, this.lineInfo, this.facts);
final String relativePath;
final LineInfo lineInfo;
final List<SwallowedFailure> facts;
final List<String> _classStack = [];
/// Enclosing named members (method, function, constructor), innermost last,
/// so a catch in a closure is placed relative to the member that holds it.
final List<({String name, AstNode node})> _members = [];
static final RegExp _stateAssign = RegExp(r'(^|[^.\w])state\s*=(?!=)');
static final RegExp _throwing = RegExp(r'\b(rethrow|throw)\b');
static final RegExp _errorish = RegExp(
r'(Error|Failure|Fail|Cancel|Retry|Recover|Reset|Clear)',
caseSensitive: false,
);
@override
void visitClassDeclaration(ClassDeclaration node) {
_classStack.add(node.namePart.typeName.lexeme);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMixinDeclaration(MixinDeclaration node) {
_classStack.add(node.name.lexeme);
super.visitMixinDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
_members.add((name: node.name.lexeme, node: node));
_analyze(node.body, node.name.lexeme);
super.visitMethodDeclaration(node);
_members.removeLast();
}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {
_members.add((name: node.name.lexeme, node: node));
super.visitFunctionDeclaration(node);
_members.removeLast();
}
@override
void visitConstructorDeclaration(ConstructorDeclaration node) {
final name = node.name;
_members.add((
name: name == null ? 'new' : 'new ${name.lexeme}',
node: node,
));
super.visitConstructorDeclaration(node);
_members.removeLast();
}
/// `member#index`: the catch's position among every catch clause of its
/// enclosing member, in source order. Unlike its line, this does not change
/// when code outside that member moves.
String _catchSite(CatchClause clause, FunctionBody body) {
final enclosing = _members.isEmpty ? null : _members.last;
final collector = _CatchCollector();
(enclosing?.node ?? body).accept(collector);
final index = collector.clauses.indexWhere(
(candidate) => identical(candidate, clause),
);
return '${enclosing?.name ?? '<closure>'}#$index';
}
/// The catch body as bounded evidence: string literals elided, one line,
/// capped (see [sourceExcerpt]).
static String _catchExcerpt(CatchClause clause) {
final excerpt = sourceExcerpt(clause.body);
return excerpt.isEmpty ? '<empty catch>' : excerpt;
}
@override
void visitFunctionExpression(FunctionExpression node) {
_analyze(node.body, _closureMemberName(node));
super.visitFunctionExpression(node);
}
void _analyze(FunctionBody? body, String member) {
if (body == null) return;
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
final scan = _BodyScan();
body.accept(scan);
if (scan.loadingEmits.isEmpty) return;
for (final statement in scan.topLevelTries) {
if (statement.catchClauses.isEmpty) continue;
final probe = _HasAwait();
statement.body.accept(probe);
if (!probe.found) continue;
for (final catchClause in statement.catchClauses) {
final source = catchClause.body.toSource();
final emits =
source.contains('emit(') || source.contains('notifyListeners(');
final assignsState = _stateAssign.hasMatch(source);
final rethrows = _throwing.hasMatch(source);
final dispatchesFailure =
source.contains('add(') && _errorish.hasMatch(source);
if (emits || assignsState || rethrows || dispatchesFailure) continue;
facts.add(
SwallowedFailure(
owner: owner,
member: member,
path: relativePath,
line: lineInfo.getLocation(catchClause.offset).lineNumber,
site: _catchSite(catchClause, body),
handler: _catchExcerpt(catchClause),
),
);
}
}
}
}
class _CatchCollector extends RecursiveAstVisitor<void> {
final List<CatchClause> clauses = [];
@override
void visitCatchClause(CatchClause node) {
clauses.add(node);
super.visitCatchClause(node);
}
}

View file

@ -0,0 +1,549 @@
/// The Dart front end's recognisers for the `ChangeNotifier` / `provider`
/// rules. They emit the neutral facts in facts.dart; provider_rules.dart judges
/// them and never sees a syntax tree.
library;
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';
import 'excerpt.dart';
import 'facts.dart';
import 'project.dart';
/// Recognises `ChangeNotifier` classes (by supertype or mixin name).
class _NotifierCollector extends RecursiveAstVisitor<void> {
_NotifierCollector(this.relativePath, this.lineInfo, this.notifiers);
final String relativePath;
final LineInfo lineInfo;
final List<NotifierFact> notifiers;
static final RegExp _notify = RegExp(
r'notifyListeners',
caseSensitive: false,
);
static bool _isChangeNotifier(ClassDeclaration node) {
final superclass = node.extendsClause?.superclass.toSource() ?? '';
if (superclass.contains('ChangeNotifier')) return true;
return (node.withClause?.mixinTypes ?? const <NamedType>[]).any(
(type) => type.toSource().contains('ChangeNotifier'),
);
}
@override
void visitClassDeclaration(ClassDeclaration node) {
if (!_isChangeNotifier(node)) {
super.visitClassDeclaration(node);
return;
}
final fields = <String>{};
for (final member in node.body.members) {
if (member is! FieldDeclaration) continue;
for (final variable in member.fields.variables) {
fields.add(variable.name.lexeme);
}
}
final methods = <String, NotifierMethod>{};
for (final member in node.body.members) {
if (member is! MethodDeclaration) continue;
if (member.isGetter) continue;
if (member.isStatic) continue;
final source = member.toSource();
final called = _CalledNames();
member.body.accept(called);
final mutations = <FieldMutation>[];
for (final field in fields) {
// `_x = ...` or `this._x = ...`, but not `==`.
final pattern = RegExp(
r'(^|[^.\w])(this\.)?' + RegExp.escape(field) + r'\s*=(?!=)',
);
if (pattern.hasMatch(source)) {
mutations.add(
FieldMutation(
field,
lineInfo.getLocation(member.offset).lineNumber,
),
);
}
}
methods[member.name.lexeme] = NotifierMethod(
name: member.name.lexeme,
calledNames: called.names,
directNotify: _notify.hasMatch(source),
mutations: mutations,
);
}
// What the UI can observe.
final observable = <String>{
for (final field in fields)
if (!field.startsWith('_')) field,
};
final apiMembers = <String>{
for (final field in fields)
if (!field.startsWith('_')) field,
};
for (final member in node.body.members) {
if (member is! MethodDeclaration) continue;
if (!member.isGetter) continue;
if (member.name.lexeme.startsWith('_')) continue;
apiMembers.add(member.name.lexeme);
final source = member.toSource();
for (final field in fields) {
if (RegExp(r'\b' + RegExp.escape(field) + r'\b').hasMatch(source)) {
observable.add(field);
}
}
}
if (methods.isNotEmpty) {
notifiers.add(
NotifierFact(
name: node.namePart.typeName.lexeme,
file: relativePath,
line: lineInfo.getLocation(node.offset).lineNumber,
methods: methods.values.toList(),
observableFields: observable,
apiMembers: apiMembers,
),
);
}
super.visitClassDeclaration(node);
}
}
/// Method names invoked on `this` (or unqualified) within a body.
class _CalledNames extends RecursiveAstVisitor<void> {
final Set<String> names = {};
@override
void visitMethodInvocation(MethodInvocation node) {
final target = node.target;
if (target == null || target is ThisExpression) {
names.add(node.methodName.name);
}
super.visitMethodInvocation(node);
}
@override
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
final function = node.function;
if (function is SimpleIdentifier) names.add(function.name);
super.visitFunctionExpressionInvocation(node);
}
}
/// Consumer sites that read a notifier, used to say whether a Consumer can ever
/// rebuild.
class _ConsumerCollector extends RecursiveAstVisitor<void> {
_ConsumerCollector(this.relativePath, this.lineInfo, this.consumedBy);
final String relativePath;
final LineInfo lineInfo;
final Map<String, List<String>> consumedBy;
static const Set<String> _widgets = {
'Consumer',
'Selector',
'ChangeNotifierProvider',
'ListenableBuilder',
'AnimatedBuilder',
};
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
final type = node.constructorName.type;
final name = type.name.lexeme;
if (!_widgets.contains(name)) {
super.visitInstanceCreationExpression(node);
return;
}
for (final argument
in type.typeArguments?.arguments ?? const <TypeAnnotation>[]) {
final typeName = argument.toSource().split('<').first.trim();
consumedBy
.putIfAbsent(typeName, () => [])
.add('$relativePath:${lineInfo.getLocation(node.offset).lineNumber}');
}
super.visitInstanceCreationExpression(node);
}
@override
void visitMethodInvocation(MethodInvocation node) {
final name = node.methodName.name;
if (name == 'watch' || name == 'read' || name == 'of') {
for (final argument
in node.typeArguments?.arguments ?? const <TypeAnnotation>[]) {
final typeName = argument.toSource().split('<').first.trim();
consumedBy
.putIfAbsent(typeName, () => [])
.add(
'$relativePath:${lineInfo.getLocation(node.offset).lineNumber}',
);
}
}
super.visitMethodInvocation(node);
}
}
/// Collects `provider` package provider declarations (all provider types).
class _ProviderDeclarationCollector extends RecursiveAstVisitor<void> {
_ProviderDeclarationCollector(
this.relativePath,
this.lineInfo,
this.declarations,
);
final String relativePath;
final LineInfo lineInfo;
final List<ProviderDeclaration> declarations;
static const Set<String> kinds = {
'Provider',
'ChangeNotifierProvider',
'ListenableProvider',
'ValueListenableProvider',
'FutureProvider',
'StreamProvider',
'StateProvider',
'ProxyProvider',
'ChangeNotifierProxyProvider',
'InheritedProvider',
};
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
final type = node.constructorName.type;
var kind = type.name.lexeme;
if (kind.endsWith('ProxyProvider')) kind = 'ProxyProvider';
if (!kinds.contains(kind)) {
super.visitInstanceCreationExpression(node);
return;
}
// The provider package takes `create:`/`update:`; Riverpod passes a
// positional `(ref)` callback. That plus a `package:provider` import keeps
// the two apart.
final named = <String>{};
for (final argument in node.argumentList.arguments) {
if (argument is NamedArgument) named.add(argument.name.lexeme);
}
final looksLikeRiverpod =
!named.contains('create') &&
!named.contains('update') &&
!named.contains('value') &&
node.constructorName.name == null;
if (looksLikeRiverpod) {
super.visitInstanceCreationExpression(node);
return;
}
final arguments = type.typeArguments?.arguments ?? const <TypeAnnotation>[];
declarations.add(
ProviderDeclaration(
kind: kind,
valueType: arguments.isEmpty
? null
: arguments.first.toSource().split('<').first.trim(),
file: relativePath,
line: lineInfo.getLocation(node.offset).lineNumber,
),
);
super.visitInstanceCreationExpression(node);
}
}
/// Finds `context.read<T>()` / `Provider.of<T>(context, listen: false)` used
/// inside a `build` method for a type that is a `Listenable`.
///
/// `read` never subscribes, so a widget that displays such a value will not
/// rebuild when it changes. This targets ChangeNotifier-provided state, which is
/// why it only fires for listenable provider types or known ChangeNotifiers.
class _ReadInBuildCollector extends RecursiveAstVisitor<void> {
_ReadInBuildCollector(this.relativePath, this.lineInfo, this.reads);
final String relativePath;
final LineInfo lineInfo;
final List<NonSubscribingRead> reads;
bool _isBuildMethod(MethodDeclaration node) {
if (node.name.lexeme != 'build') return false;
final returnType = node.returnType?.toSource() ?? '';
if (returnType.contains('Widget')) return true;
final parameters = node.parameters;
if (parameters == null) return false;
for (final parameter in parameters.parameters) {
if (parameter.toSource().contains('BuildContext')) return true;
}
return false;
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
if (!_isBuildMethod(node)) {
super.visitMethodDeclaration(node);
return;
}
final collector = _ReadCallCollector();
node.body.accept(collector);
for (final call in collector.calls) {
reads.add(
NonSubscribingRead(
type: call.type,
call: call.source,
excerpt: call.excerpt,
path: relativePath,
line: lineInfo.getLocation(call.offset).lineNumber,
),
);
}
// Still descend: a nested widget may have its own build method.
super.visitMethodDeclaration(node);
}
}
class _ReadCall {
_ReadCall(this.type, this.offset, this.source, this.excerpt);
final String type;
final int offset;
/// The call's full source. Part of the finding's identity (`call`), so it is
/// kept exactly as before; it is never quoted in a message or evidence.
final String source;
/// Bounded, literal-free excerpt for messages and evidence.
final String excerpt;
}
/// Collects non-subscribing reads made directly in a build body.
///
/// It deliberately does NOT descend into nested functions: a read inside a
/// closure (a button callback, a BlocListener listener, a Selector builder) is
/// not made while the build method runs, so not subscribing there is usually
/// exactly right. Hand-checking the first real findings showed every one of them
/// was a closure read, which is why this is a precision guard rather than an
/// oversight - it costs recall on reads inside a `Builder(builder: ...)`.
class _ReadCallCollector extends RecursiveAstVisitor<void> {
final List<_ReadCall> calls = [];
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
@override
void visitMethodInvocation(MethodInvocation node) {
final arguments = node.typeArguments?.arguments ?? const <TypeAnnotation>[];
if (arguments.isNotEmpty) {
final type = arguments.first.toSource().split('<').first.trim();
final name = node.methodName.name;
final target = node.target?.toSource();
// context.read<T>()
if (name == 'read' && (target == 'context' || target == 'this.context')) {
calls.add(
_ReadCall(type, node.offset, node.toSource(), sourceExcerpt(node)),
);
}
// Provider.of<T>(context, listen: false)
if (name == 'of' && (target == 'Provider' || target == 'provider')) {
final listens = node.argumentList.arguments.any(
(argument) =>
argument is NamedArgument &&
argument.name.lexeme == 'listen' &&
argument.argumentExpression.toSource().trim() == 'false',
);
if (listens) {
calls.add(
_ReadCall(type, node.offset, node.toSource(), sourceExcerpt(node)),
);
}
}
}
super.visitMethodInvocation(node);
}
}
/// Collects member reads (`x.someGetter`, `x.someField`) per file, so a public
/// notifier getter that nothing reads can be identified as dead state.
class _MemberUseCollector extends RecursiveAstVisitor<void> {
_MemberUseCollector(this.uses);
final Set<String> uses;
@override
void visitPropertyAccess(PropertyAccess node) {
uses.add(node.propertyName.name);
super.visitPropertyAccess(node);
}
@override
void visitPrefixedIdentifier(PrefixedIdentifier node) {
uses.add(node.identifier.name);
super.visitPrefixedIdentifier(node);
}
}
/// Finds a provider constructed directly inside a build method.
///
/// Without `.value` the provider's `create` callback runs on every rebuild, so a
/// new notifier (and a new listener registration) is created each time and any
/// state it held is lost.
class _ProviderInBuildCollector extends RecursiveAstVisitor<void> {
_ProviderInBuildCollector(this.relativePath, this.lineInfo, this.facts);
final String relativePath;
final LineInfo lineInfo;
final List<ProvidersCreatedInBuild> facts;
final List<String> _classStack = [];
static const Set<String> kinds = {
'Provider',
'ChangeNotifierProvider',
'ListenableProvider',
'ValueListenableProvider',
'ProxyProvider',
'ChangeNotifierProxyProvider',
};
bool _isBuildMethod(MethodDeclaration node) {
if (node.name.lexeme != 'build') return false;
if ((node.returnType?.toSource() ?? '').contains('Widget')) return true;
final parameters = node.parameters;
if (parameters == null) return false;
return parameters.parameters.any(
(parameter) => parameter.toSource().contains('BuildContext'),
);
}
@override
void visitClassDeclaration(ClassDeclaration node) {
_classStack.add(node.namePart.typeName.lexeme);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
if (!_isBuildMethod(node)) {
super.visitMethodDeclaration(node);
return;
}
final collector = _ProviderCreationCollector();
node.body.accept(collector);
if (collector.creations.isEmpty) {
super.visitMethodDeclaration(node);
return;
}
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
facts.add(
ProvidersCreatedInBuild(
owner: owner,
path: relativePath,
line: lineInfo.getLocation(node.offset).lineNumber,
creations: [
for (final creation in collector.creations)
ProviderCreation(
kind: creation.kind,
valueType: creation.valueType,
line: lineInfo.getLocation(creation.offset).lineNumber,
),
],
),
);
super.visitMethodDeclaration(node);
}
}
class _Creation {
_Creation(this.kind, this.valueType, this.offset, this.source);
final String kind;
final String valueType;
final int offset;
final String source;
}
class _ProviderCreationCollector extends RecursiveAstVisitor<void> {
final List<_Creation> creations = [];
// A closure runs later, so a provider created there is a different problem.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
final type = node.constructorName.type;
var kind = type.name.lexeme;
if (kind.endsWith('ProxyProvider')) kind = 'ProxyProvider';
// `.value` passes an existing instance, which is correct.
if (node.constructorName.name?.name == 'value') {
super.visitInstanceCreationExpression(node);
return;
}
if (_ProviderInBuildCollector.kinds.contains(kind)) {
final arguments =
type.typeArguments?.arguments ?? const <TypeAnnotation>[];
creations.add(
_Creation(
kind,
arguments.isEmpty
? '?'
: arguments.first.toSource().split('<').first.trim(),
node.offset,
node.toSource(),
),
);
}
super.visitInstanceCreationExpression(node);
}
}
/// Collect the notifier/provider facts from resolved units, in unit order.
NotifierModel collectNotifierModel(
Project project,
Map<String, ResolvedUnitResult> unitsByPath,
) {
final notifiers = <NotifierFact>[];
final consumedBy = <String, List<String>>{};
final declarations = <ProviderDeclaration>[];
final memberUses = <String, Set<String>>{};
final reads = <NonSubscribingRead>[];
final createdInBuild = <ProvidersCreatedInBuild>[];
for (final entry in unitsByPath.entries) {
final unit = entry.value.unit;
final lineInfo = entry.value.lineInfo;
final relative = project.relative(entry.key);
unit.accept(_NotifierCollector(relative, lineInfo, notifiers));
unit.accept(_ConsumerCollector(relative, lineInfo, consumedBy));
unit.accept(
_ProviderDeclarationCollector(relative, lineInfo, declarations),
);
final uses = <String>{};
unit.accept(_MemberUseCollector(uses));
memberUses[relative] = uses;
unit.accept(_ReadInBuildCollector(relative, lineInfo, reads));
unit.accept(_ProviderInBuildCollector(relative, lineInfo, createdInBuild));
}
return NotifierModel(
notifiers: notifiers,
consumedBy: consumedBy,
declarations: declarations,
memberUses: memberUses,
nonSubscribingReads: reads,
createdInBuild: createdInBuild,
);
}

458
lib/src/facts.dart Normal file
View file

@ -0,0 +1,458 @@
/// Language-neutral facts a front end extracts for the rules.
///
/// The rules judge these; they never read a syntax tree. A front end for
/// another language (a clang-based C++ extractor, say) produces the same facts
/// and every rule, baseline, verify and suggest path works unchanged. Each fact
/// round-trips through JSON so a front end can run as a separate process.
library;
/// A member puts its owner into an in-progress state, then awaits work whose
/// failure handler writes no state, rethrows, or dispatches a failure: the
/// busy state can outlive the failure (a spinner that never stops).
///
/// Variant style: the busy state is an emitted variant (`emit(Loading())`).
class SwallowedFailure {
SwallowedFailure({
required this.owner,
required this.member,
required this.path,
required this.line,
required this.site,
required this.handler,
});
factory SwallowedFailure.fromJson(Map<String, Object?> json) =>
SwallowedFailure(
owner: json['owner']! as String,
member: json['member']! as String,
path: json['path']! as String,
line: json['line']! as int,
site: json['site']! as String,
handler: json['handler']! as String,
);
final String owner;
final String member;
/// Project-relative path and line of the failure handler (the catch).
final String path;
final int line;
/// `member#index`: the handler's position among every catch of its
/// enclosing named member, in source order. Identity that survives code
/// moving, unlike the line.
final String site;
/// The handler as a bounded excerpt with string literals elided: analysed
/// source is untrusted, and evidence reaches agent prompts.
final String handler;
Map<String, Object?> toJson() => {
'owner': owner,
'member': member,
'path': path,
'line': line,
'site': site,
'handler': handler,
};
}
/// A member leaves a loading FLAG set across awaited work that no failure
/// handler resolves.
///
/// Flag style: the state is one class with a `bool loading`-like field, so
/// there is no variant to match on.
class UnsettledFlag {
UnsettledFlag({
required this.owner,
required this.member,
required this.path,
required this.line,
required this.stateType,
required this.fields,
required this.defaultsTrue,
required this.setsFlag,
required this.guardedTrySeen,
});
factory UnsettledFlag.fromJson(Map<String, Object?> json) => UnsettledFlag(
owner: json['owner']! as String,
member: json['member']! as String,
path: json['path']! as String,
line: json['line']! as int,
stateType: json['stateType']! as String,
fields: [...(json['fields']! as List).cast<String>()],
defaultsTrue: json['defaultsTrue']! as bool,
setsFlag: json['setsFlag']! as bool,
guardedTrySeen: json['guardedTrySeen']! as bool,
);
final String owner;
final String member;
final String path;
final int line;
final String stateType;
final List<String> fields;
final bool defaultsTrue;
/// The member itself sets the flag (as opposed to inheriting a default-true
/// flag by running from the constructor).
final bool setsFlag;
/// Some awaited work sat inside a try, though none of its catches resolved
/// the flag. Weaker evidence than no try at all.
final bool guardedTrySeen;
Map<String, Object?> toJson() => {
'owner': owner,
'member': member,
'path': path,
'line': line,
'stateType': stateType,
'fields': fields,
'defaultsTrue': defaultsTrue,
'setsFlag': setsFlag,
'guardedTrySeen': guardedTrySeen,
};
}
/// Files whose references did not resolve. Evidence in them can be missing,
/// so findings naming anything they mention are weaker.
class UnresolvedSummary {
UnresolvedSummary({
required this.files,
required this.analysed,
this.first,
this.names = const {},
});
factory UnresolvedSummary.fromJson(Map<String, Object?> json) =>
UnresolvedSummary(
files: [...(json['files']! as List).cast<String>()],
analysed: json['analysed']! as int,
first: json['first'] as String?,
names: {...(json['names']! as List).cast<String>()},
);
/// Project-relative paths.
final List<String> files;
final int analysed;
/// The first unresolved reference as `path:line: message`.
final String? first;
/// Every name those files mention.
final Set<String> names;
bool get any => files.isNotEmpty;
Map<String, Object?> toJson() => {
'files': files,
'analysed': analysed,
if (first != null) 'first': first,
'names': names.toList()..sort(),
};
}
/// An observable object that must announce its own changes: a Dart
/// `ChangeNotifier`, a Qt `QObject` with signals, a hand-rolled observer
/// subject. Whether a mutation is ever announced is the rules' judgement.
class NotifierFact {
NotifierFact({
required this.name,
required this.file,
required this.line,
required this.methods,
required this.observableFields,
required this.apiMembers,
});
factory NotifierFact.fromJson(Map<String, Object?> json) => NotifierFact(
name: json['name']! as String,
file: json['file']! as String,
line: json['line']! as int,
methods: [
for (final method in json['methods']! as List)
NotifierMethod.fromJson(method as Map<String, Object?>),
],
observableFields: {...(json['observableFields']! as List).cast<String>()},
apiMembers: {...(json['apiMembers']! as List).cast<String>()},
);
final String name;
final String file;
final int line;
/// Instance methods, in declaration order.
final List<NotifierMethod> methods;
/// State a consumer can observe (public fields, and private fields a public
/// getter exposes).
final Set<String> observableFields;
/// The public read surface: public getters and fields.
final Set<String> apiMembers;
Map<String, Object?> toJson() => {
'name': name,
'file': file,
'line': line,
'methods': [for (final method in methods) method.toJson()],
'observableFields': observableFields.toList()..sort(),
'apiMembers': apiMembers.toList()..sort(),
};
}
class NotifierMethod {
NotifierMethod({
required this.name,
required this.calledNames,
required this.directNotify,
required this.mutations,
});
factory NotifierMethod.fromJson(Map<String, Object?> json) => NotifierMethod(
name: json['name']! as String,
calledNames: {...(json['calledNames']! as List).cast<String>()},
directNotify: json['directNotify']! as bool,
mutations: [
for (final mutation in json['mutations']! as List)
FieldMutation.fromJson(mutation as Map<String, Object?>),
],
);
final String name;
/// Methods it calls on its own object.
final Set<String> calledNames;
/// It announces a change itself (`notifyListeners()`, `emit signal`).
final bool directNotify;
/// Fields it assigns, in the order they were found.
final List<FieldMutation> mutations;
Map<String, Object?> toJson() => {
'name': name,
'calledNames': calledNames.toList()..sort(),
'directNotify': directNotify,
'mutations': [for (final mutation in mutations) mutation.toJson()],
};
}
class FieldMutation {
FieldMutation(this.name, this.line);
factory FieldMutation.fromJson(Map<String, Object?> json) =>
FieldMutation(json['name']! as String, json['line']! as int);
final String name;
final int line;
Map<String, Object?> toJson() => {'name': name, 'line': line};
}
/// A read of a provided value made directly while building UI, without
/// subscribing to it (`context.read<T>()`). Whether T is observable is the
/// rule's judgement.
class NonSubscribingRead {
NonSubscribingRead({
required this.type,
required this.call,
required this.excerpt,
required this.path,
required this.line,
});
factory NonSubscribingRead.fromJson(Map<String, Object?> json) =>
NonSubscribingRead(
type: json['type']! as String,
call: json['call']! as String,
excerpt: json['excerpt']! as String,
path: json['path']! as String,
line: json['line']! as int,
);
final String type;
/// The call's full source: part of the finding's identity, never quoted.
final String call;
/// Bounded, literal-free excerpt of the call, for messages and evidence.
final String excerpt;
final String path;
final int line;
Map<String, Object?> toJson() => {
'type': type,
'call': call,
'excerpt': excerpt,
'path': path,
'line': line,
};
}
/// A build method that constructs providers without reusing an instance, so
/// their create callbacks rerun on every rebuild.
class ProvidersCreatedInBuild {
ProvidersCreatedInBuild({
required this.owner,
required this.path,
required this.line,
required this.creations,
});
factory ProvidersCreatedInBuild.fromJson(Map<String, Object?> json) =>
ProvidersCreatedInBuild(
owner: json['owner']! as String,
path: json['path']! as String,
line: json['line']! as int,
creations: [
for (final creation in json['creations']! as List)
ProviderCreation.fromJson(creation as Map<String, Object?>),
],
);
final String owner;
final String path;
final int line;
/// In source order.
final List<ProviderCreation> creations;
Map<String, Object?> toJson() => {
'owner': owner,
'path': path,
'line': line,
'creations': [for (final creation in creations) creation.toJson()],
};
}
class ProviderCreation {
ProviderCreation({
required this.kind,
required this.valueType,
required this.line,
});
factory ProviderCreation.fromJson(Map<String, Object?> json) =>
ProviderCreation(
kind: json['kind']! as String,
valueType: json['valueType']! as String,
line: json['line']! as int,
);
final String kind;
final String valueType;
final int line;
Map<String, Object?> toJson() => {
'kind': kind,
'valueType': valueType,
'line': line,
};
}
/// Everything the notifier/provider rules judge.
class NotifierModel {
NotifierModel({
this.notifiers = const [],
this.consumedBy = const {},
this.declarations = const [],
this.memberUses = const {},
this.nonSubscribingReads = const [],
this.createdInBuild = const [],
});
factory NotifierModel.fromJson(Map<String, Object?> json) => NotifierModel(
notifiers: [
for (final notifier in json['notifiers']! as List)
NotifierFact.fromJson(notifier as Map<String, Object?>),
],
consumedBy: {
for (final entry in (json['consumedBy']! as Map).entries)
entry.key as String: [...(entry.value as List).cast<String>()],
},
declarations: [
for (final declaration in json['declarations']! as List)
ProviderDeclaration.fromJson(declaration as Map<String, Object?>),
],
memberUses: {
for (final entry in (json['memberUses']! as Map).entries)
entry.key as String: {...(entry.value as List).cast<String>()},
},
nonSubscribingReads: [
for (final read in json['nonSubscribingReads']! as List)
NonSubscribingRead.fromJson(read as Map<String, Object?>),
],
createdInBuild: [
for (final build in json['createdInBuild']! as List)
ProvidersCreatedInBuild.fromJson(build as Map<String, Object?>),
],
);
final List<NotifierFact> notifiers;
/// Observed type -> consumer sites (`path:line`), in source order.
final Map<String, List<String>> consumedBy;
final List<ProviderDeclaration> declarations;
/// Project-relative file -> member names read in it.
final Map<String, Set<String>> memberUses;
final List<NonSubscribingRead> nonSubscribingReads;
final List<ProvidersCreatedInBuild> createdInBuild;
Map<String, Object?> toJson() => {
'notifiers': [for (final notifier in notifiers) notifier.toJson()],
'consumedBy': consumedBy,
'declarations': [
for (final declaration in declarations) declaration.toJson(),
],
'memberUses': {
for (final entry in memberUses.entries)
entry.key: entry.value.toList()..sort(),
},
'nonSubscribingReads': [
for (final read in nonSubscribingReads) read.toJson(),
],
'createdInBuild': [for (final build in createdInBuild) build.toJson()],
};
}
/// A provider declaration (`ChangeNotifierProvider<T>(create: ...)`).
class ProviderDeclaration {
ProviderDeclaration({
required this.kind,
required this.valueType,
required this.file,
required this.line,
});
factory ProviderDeclaration.fromJson(Map<String, Object?> json) =>
ProviderDeclaration(
kind: json['kind']! as String,
valueType: json['valueType'] as String?,
file: json['file']! as String,
line: json['line']! as int,
);
/// `Provider`, `ChangeNotifierProvider`, `ProxyProvider`, ...
final String kind;
final String? valueType;
final String file;
final int line;
/// Providers whose value is a `Listenable`, so consumers must subscribe.
bool get isListenable => const {
'ChangeNotifierProvider',
'ListenableProvider',
'ValueListenableProvider',
}.contains(kind);
Map<String, Object?> toJson() => {
'kind': kind,
if (valueType != null) 'valueType': valueType,
'file': file,
'line': line,
};
}

View file

@ -1,3 +1,6 @@
import 'dart:io';
import 'analysis.dart';
import 'firestore_facts.dart';
import 'gateway_facts.dart';
import 'index.dart';
@ -51,15 +54,25 @@ Future<ProjectAnalysis> analyzeProject(
);
try {
final flow = buildStateFlow(project, run.outcomes);
final unresolved = findUnresolvedReferences(run.outcomes);
if (unresolved.any) {
stderr.writeln(
'pipe: WARNING ${unresolved.files.length} of ${unresolved.analysed} '
'analysed file(s) have unresolved references, so findings naming '
'anything they mention may be false and have lowered confidence. Run '
'`pipe index` to see why.',
);
}
final firestore = buildFirestoreModel(
project,
run.outcomes,
adapters: adapters,
);
final report = runChecks(
project: project,
outcomes: run.outcomes,
flow: flow,
// The rules never see source files, but every report must say which
// files it covers; the model carries that from the front end.
coverage: flow.coverage,
enabledRules: enabledRules,
baseline: baseline,
policy: policy,

View file

@ -1,12 +1,3 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';
import 'analysis.dart';
import 'excerpt.dart';
import 'project.dart';
import 'rules.dart';
/// `ChangeNotifier` / `provider` rules.
///
/// These target the `provider` half of the state-management surface, where the
@ -15,12 +6,13 @@ import 'rules.dart';
/// code often routes through a private wrapper (`_notifyListeners()`), so
/// "did it notify?" has to be answered transitively over the class's own call
/// graph rather than by looking for the literal call.
class _FieldMutation {
_FieldMutation(this.name, this.line);
///
/// The rules judge the facts in [NotifierModel]; the Dart recognisers that
/// produce them live in dart_provider_facts.dart.
library;
final String name;
final int line;
}
import 'facts.dart';
import 'rules.dart';
/// A notify wrapper `_notifyListeners()` may be declared in a base class or
/// mixin, so it is recognised by naming convention. Anchored on purpose: a
@ -35,682 +27,19 @@ const Set<String> _lifecycleMethods = {
'debugFillProperties',
};
class _MethodInfo {
_MethodInfo({
required this.name,
required this.source,
required this.offset,
required this.calledNames,
required this.directNotify,
required this.mutations,
});
final String name;
final String source;
final int offset;
final Set<String> calledNames;
final bool directNotify;
final List<_FieldMutation> mutations;
}
class _NotifierInfo {
_NotifierInfo({
required this.name,
required this.file,
required this.offset,
required this.line,
required this.methods,
required this.observableFields,
required this.apiMembers,
});
final String name;
final String file;
final int offset;
final int line;
final Map<String, _MethodInfo> methods;
/// Fields the UI can actually observe: public fields, plus private fields that
/// a public getter exposes. Internal bookkeeping (`_disposed`, `_idleTimer`)
/// is excluded, because mutating it invisible does not affect the user.
final Set<String> observableFields;
/// The public read surface: public getters and public fields. This is what a
/// consumer could display, so a member here that nothing reads is dead state.
final Set<String> apiMembers;
/// Methods that reach a `notifyListeners` call through the class's own calls.
final Set<String> notifying = {};
/// True when the class declares a notify wrapper (e.g. `_notifyListeners`).
bool get hasNotifyWrapper =>
methods.values.any((m) => m.directNotify) ||
methods.keys.any((name) => name.toLowerCase().contains('notify'));
}
class _NotifierCollector extends RecursiveAstVisitor<void> {
_NotifierCollector(this.project, this.path, this.lineInfo, this.notifiers);
final Project project;
final String path;
final LineInfo lineInfo;
final List<_NotifierInfo> notifiers;
static final RegExp _notify = RegExp(
r'notifyListeners',
caseSensitive: false,
);
static bool _isChangeNotifier(ClassDeclaration node) {
final superclass = node.extendsClause?.superclass.toSource() ?? '';
if (superclass.contains('ChangeNotifier')) return true;
return (node.withClause?.mixinTypes ?? const <NamedType>[]).any(
(type) => type.toSource().contains('ChangeNotifier'),
);
}
@override
void visitClassDeclaration(ClassDeclaration node) {
if (!_isChangeNotifier(node)) {
super.visitClassDeclaration(node);
return;
}
final fields = <String>{};
for (final member in node.body.members) {
if (member is! FieldDeclaration) continue;
for (final variable in member.fields.variables) {
fields.add(variable.name.lexeme);
}
}
final methods = <String, _MethodInfo>{};
for (final member in node.body.members) {
if (member is! MethodDeclaration) continue;
if (member.isGetter) continue;
if (member.isStatic) continue;
final source = member.toSource();
final called = _CalledNames();
member.body.accept(called);
final mutations = <_FieldMutation>[];
for (final field in fields) {
// `_x = ...` or `this._x = ...`, but not `==`.
final pattern = RegExp(
r'(^|[^.\w])(this\.)?' + RegExp.escape(field) + r'\s*=(?!=)',
);
if (pattern.hasMatch(source)) {
mutations.add(
_FieldMutation(
field,
lineInfo.getLocation(member.offset).lineNumber,
),
);
}
}
methods[member.name.lexeme] = _MethodInfo(
name: member.name.lexeme,
source: source,
offset: member.offset,
calledNames: called.names,
directNotify: _notify.hasMatch(source),
mutations: mutations,
);
}
// What the UI can observe.
final observable = <String>{
for (final field in fields)
if (!field.startsWith('_')) field,
};
final apiMembers = <String>{
for (final field in fields)
if (!field.startsWith('_')) field,
};
for (final member in node.body.members) {
if (member is! MethodDeclaration) continue;
if (!member.isGetter) continue;
if (member.name.lexeme.startsWith('_')) continue;
apiMembers.add(member.name.lexeme);
final source = member.toSource();
for (final field in fields) {
if (RegExp(r'\b' + RegExp.escape(field) + r'\b').hasMatch(source)) {
observable.add(field);
}
}
}
if (methods.isNotEmpty) {
notifiers.add(
_NotifierInfo(
name: node.namePart.typeName.lexeme,
file: project.relative(path),
offset: node.offset,
line: lineInfo.getLocation(node.offset).lineNumber,
methods: methods,
observableFields: observable,
apiMembers: apiMembers,
),
);
}
super.visitClassDeclaration(node);
}
}
/// Method names invoked on `this` (or unqualified) within a body.
class _CalledNames extends RecursiveAstVisitor<void> {
final Set<String> names = {};
@override
void visitMethodInvocation(MethodInvocation node) {
final target = node.target;
if (target == null || target is ThisExpression) {
names.add(node.methodName.name);
}
super.visitMethodInvocation(node);
}
@override
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
final function = node.function;
if (function is SimpleIdentifier) names.add(function.name);
super.visitFunctionExpressionInvocation(node);
}
}
/// Consumer sites that read a notifier, used to say whether a Consumer can ever
/// rebuild.
class _ConsumerCollector extends RecursiveAstVisitor<void> {
_ConsumerCollector(this.project, this.path, this.lineInfo, this.consumedBy);
final Project project;
final String path;
final LineInfo lineInfo;
final Map<String, List<String>> consumedBy;
static const Set<String> _widgets = {
'Consumer',
'Selector',
'ChangeNotifierProvider',
'ListenableBuilder',
'AnimatedBuilder',
};
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
final type = node.constructorName.type;
final name = type.name.lexeme;
if (!_widgets.contains(name)) {
super.visitInstanceCreationExpression(node);
return;
}
for (final argument
in type.typeArguments?.arguments ?? const <TypeAnnotation>[]) {
final typeName = argument.toSource().split('<').first.trim();
consumedBy
.putIfAbsent(typeName, () => [])
.add(
'${project.relative(path)}:'
'${lineInfo.getLocation(node.offset).lineNumber}',
);
}
super.visitInstanceCreationExpression(node);
}
@override
void visitMethodInvocation(MethodInvocation node) {
final name = node.methodName.name;
if (name == 'watch' || name == 'read' || name == 'of') {
for (final argument
in node.typeArguments?.arguments ?? const <TypeAnnotation>[]) {
final typeName = argument.toSource().split('<').first.trim();
consumedBy
.putIfAbsent(typeName, () => [])
.add(
'${project.relative(path)}:'
'${lineInfo.getLocation(node.offset).lineNumber}',
);
}
}
super.visitMethodInvocation(node);
}
}
/// A `provider` package provider declaration.
class ProviderDeclaration {
ProviderDeclaration({
required this.kind,
required this.valueType,
required this.file,
required this.line,
});
/// `Provider`, `ChangeNotifierProvider`, `ProxyProvider`, ...
final String kind;
final String? valueType;
final String file;
final int line;
/// Providers whose value is a `Listenable`, so consumers must subscribe.
bool get isListenable => const {
'ChangeNotifierProvider',
'ListenableProvider',
'ValueListenableProvider',
}.contains(kind);
Map<String, Object?> toJson() => {
'kind': kind,
if (valueType != null) 'valueType': valueType,
'file': file,
'line': line,
};
}
/// Collects `provider` package provider declarations (all provider types).
class _ProviderDeclarationCollector extends RecursiveAstVisitor<void> {
_ProviderDeclarationCollector(
this.project,
this.path,
this.lineInfo,
this.declarations,
);
final Project project;
final String path;
final LineInfo lineInfo;
final List<ProviderDeclaration> declarations;
static const Set<String> kinds = {
'Provider',
'ChangeNotifierProvider',
'ListenableProvider',
'ValueListenableProvider',
'FutureProvider',
'StreamProvider',
'StateProvider',
'ProxyProvider',
'ChangeNotifierProxyProvider',
'InheritedProvider',
};
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
final type = node.constructorName.type;
var kind = type.name.lexeme;
if (kind.endsWith('ProxyProvider')) kind = 'ProxyProvider';
if (!kinds.contains(kind)) {
super.visitInstanceCreationExpression(node);
return;
}
// The provider package takes `create:`/`update:`; Riverpod passes a
// positional `(ref)` callback. That plus a `package:provider` import keeps
// the two apart.
final named = <String>{};
for (final argument in node.argumentList.arguments) {
if (argument is NamedArgument) named.add(argument.name.lexeme);
}
final looksLikeRiverpod =
!named.contains('create') &&
!named.contains('update') &&
!named.contains('value') &&
node.constructorName.name == null;
if (looksLikeRiverpod) {
super.visitInstanceCreationExpression(node);
return;
}
final arguments = type.typeArguments?.arguments ?? const <TypeAnnotation>[];
declarations.add(
ProviderDeclaration(
kind: kind,
valueType: arguments.isEmpty
? null
: arguments.first.toSource().split('<').first.trim(),
file: project.relative(path),
line: lineInfo.getLocation(node.offset).lineNumber,
),
);
super.visitInstanceCreationExpression(node);
}
}
/// Finds `context.read<T>()` / `Provider.of<T>(context, listen: false)` used
/// inside a `build` method for a type that is a `Listenable`.
///
/// `read` never subscribes, so a widget that displays such a value will not
/// rebuild when it changes. This targets ChangeNotifier-provided state, which is
/// why it only fires for listenable provider types or known ChangeNotifiers.
class _ReadInBuildCollector extends RecursiveAstVisitor<void> {
_ReadInBuildCollector(
this.project,
this.path,
this.lineInfo,
this.listenableTypes,
this.notifierNames,
this.findings,
);
final Project project;
final String path;
final LineInfo lineInfo;
final Set<String> listenableTypes;
final Set<String> notifierNames;
final List<Finding> findings;
bool _isBuildMethod(MethodDeclaration node) {
if (node.name.lexeme != 'build') return false;
final returnType = node.returnType?.toSource() ?? '';
if (returnType.contains('Widget')) return true;
final parameters = node.parameters;
if (parameters == null) return false;
for (final parameter in parameters.parameters) {
if (parameter.toSource().contains('BuildContext')) return true;
}
return false;
}
bool _isListened(String type) =>
listenableTypes.contains(type) || notifierNames.contains(type);
@override
void visitMethodDeclaration(MethodDeclaration node) {
if (!_isBuildMethod(node)) {
super.visitMethodDeclaration(node);
return;
}
final collector = _ReadCallCollector();
node.body.accept(collector);
for (final call in collector.calls) {
final type = call.type;
if (!_isListened(type)) continue;
findings.add(
Finding(
rule: ruleProviderReadInBuild,
severity: PipeSeverity.warning,
confidence: 'medium',
message:
'$type is read without subscribing inside build '
'(${call.excerpt}). This widget will not rebuild when $type '
'changes, so anything it renders from that value can go stale.',
file: project.relative(path),
line: lineInfo.getLocation(call.offset).lineNumber,
evidence: [
'${project.relative(path)}:'
'${lineInfo.getLocation(call.offset).lineNumber}: '
'${call.excerpt}',
],
remediation:
'Use context.watch<$type>() (or Provider.of<$type>(context)) so '
'the widget subscribes — or keep read() and make sure nothing '
'rendered depends on the value.',
data: {'valueType': type, 'call': call.source, 'member': 'build'},
),
);
}
// Still descend: a nested widget may have its own build method.
super.visitMethodDeclaration(node);
}
}
class _ReadCall {
_ReadCall(this.type, this.offset, this.source, this.excerpt);
final String type;
final int offset;
/// The call's full source. Part of the finding's identity (`call`), so it is
/// kept exactly as before; it is never quoted in a message or evidence.
final String source;
/// Bounded, literal-free excerpt for messages and evidence.
final String excerpt;
}
/// Collects non-subscribing reads made directly in a build body.
///
/// It deliberately does NOT descend into nested functions: a read inside a
/// closure (a button callback, a BlocListener listener, a Selector builder) is
/// not made while the build method runs, so not subscribing there is usually
/// exactly right. Hand-checking the first real findings showed every one of them
/// was a closure read, which is why this is a precision guard rather than an
/// oversight - it costs recall on reads inside a `Builder(builder: ...)`.
class _ReadCallCollector extends RecursiveAstVisitor<void> {
final List<_ReadCall> calls = [];
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
@override
void visitMethodInvocation(MethodInvocation node) {
final arguments = node.typeArguments?.arguments ?? const <TypeAnnotation>[];
if (arguments.isNotEmpty) {
final type = arguments.first.toSource().split('<').first.trim();
final name = node.methodName.name;
final target = node.target?.toSource();
// context.read<T>()
if (name == 'read' && (target == 'context' || target == 'this.context')) {
calls.add(
_ReadCall(type, node.offset, node.toSource(), sourceExcerpt(node)),
);
}
// Provider.of<T>(context, listen: false)
if (name == 'of' && (target == 'Provider' || target == 'provider')) {
final listens = node.argumentList.arguments.any(
(argument) =>
argument is NamedArgument &&
argument.name.lexeme == 'listen' &&
argument.argumentExpression.toSource().trim() == 'false',
);
if (listens) {
calls.add(
_ReadCall(type, node.offset, node.toSource(), sourceExcerpt(node)),
);
}
}
}
super.visitMethodInvocation(node);
}
}
/// Member names a user would notice going unrendered.
final RegExp _looksImportantMember = RegExp(
r'(error|failure|message|loading|empty|count|total|status)',
caseSensitive: false,
);
/// Collects member reads (`x.someGetter`, `x.someField`) per file, so a public
/// notifier getter that nothing reads can be identified as dead state.
class _MemberUseCollector extends RecursiveAstVisitor<void> {
_MemberUseCollector(this.uses);
final Set<String> uses;
@override
void visitPropertyAccess(PropertyAccess node) {
uses.add(node.propertyName.name);
super.visitPropertyAccess(node);
}
@override
void visitPrefixedIdentifier(PrefixedIdentifier node) {
uses.add(node.identifier.name);
super.visitPrefixedIdentifier(node);
}
}
/// Finds a provider constructed directly inside a build method.
///
/// Without `.value` the provider's `create` callback runs on every rebuild, so a
/// new notifier (and a new listener registration) is created each time and any
/// state it held is lost.
class _ProviderInBuildCollector extends RecursiveAstVisitor<void> {
_ProviderInBuildCollector(
this.project,
this.path,
this.lineInfo,
this.findings,
);
final Project project;
final String path;
final LineInfo lineInfo;
final List<Finding> findings;
final List<String> _classStack = [];
static const Set<String> kinds = {
'Provider',
'ChangeNotifierProvider',
'ListenableProvider',
'ValueListenableProvider',
'ProxyProvider',
'ChangeNotifierProxyProvider',
};
bool _isBuildMethod(MethodDeclaration node) {
if (node.name.lexeme != 'build') return false;
if ((node.returnType?.toSource() ?? '').contains('Widget')) return true;
final parameters = node.parameters;
if (parameters == null) return false;
return parameters.parameters.any(
(parameter) => parameter.toSource().contains('BuildContext'),
);
}
@override
void visitClassDeclaration(ClassDeclaration node) {
_classStack.add(node.namePart.typeName.lexeme);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
if (!_isBuildMethod(node)) {
super.visitMethodDeclaration(node);
return;
}
final collector = _ProviderCreationCollector();
node.body.accept(collector);
if (collector.creations.isEmpty) {
super.visitMethodDeclaration(node);
return;
}
// One finding per build method, not one per provider: on a real app root
// this is a single decision (hoist them or not), and 45 findings for one
// root widget buries everything else. The claim is factual - create does
// rerun on rebuild - but whether it matters depends on how often the widget
// rebuilds, which is why this is info and not a warning.
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
final kinds = <String, int>{};
final valueTypes = <String>[];
for (final creation in collector.creations) {
kinds.update(creation.kind, (v) => v + 1, ifAbsent: () => 1);
if (!valueTypes.contains(creation.valueType)) {
valueTypes.add(creation.valueType);
}
}
valueTypes.sort();
final summary = kinds.entries.map((e) => '${e.key} x${e.value}').join(', ');
final first = collector.creations.first;
findings.add(
Finding(
rule: ruleProviderCreatedInBuild,
severity: PipeSeverity.info,
confidence: 'medium',
message:
'$owner.build constructs ${collector.creations.length} provider(s) '
'without `.value` ($summary), so their create callbacks run again if '
'this widget rebuilds - new instances and listener registrations, '
'and any state they held is lost.',
file: project.relative(path),
line: lineInfo.getLocation(node.offset).lineNumber,
evidence: [
for (final creation in collector.creations.take(3))
'${project.relative(path)}:'
'${lineInfo.getLocation(creation.offset).lineNumber}: '
'${creation.kind}<${creation.valueType}>',
],
remediation:
'Hoist these above the widget that rebuilds (a parent build or the '
'app root), or use the `.value` constructor with instances created '
'once. Harmless only if this widget never rebuilds.',
data: {
'owner': owner,
'member': 'build',
'kindCounts': summary,
'valueTypes': valueTypes,
'providerCount': collector.creations.length,
'firstKind': first.kind,
},
),
);
super.visitMethodDeclaration(node);
}
}
class _ProviderCreation {
_ProviderCreation(this.kind, this.valueType, this.offset, this.source);
final String kind;
final String valueType;
final int offset;
final String source;
}
class _ProviderCreationCollector extends RecursiveAstVisitor<void> {
final List<_ProviderCreation> creations = [];
// A closure runs later, so a provider created there is a different problem.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
final type = node.constructorName.type;
var kind = type.name.lexeme;
if (kind.endsWith('ProxyProvider')) kind = 'ProxyProvider';
// `.value` passes an existing instance, which is correct.
if (node.constructorName.name?.name == 'value') {
super.visitInstanceCreationExpression(node);
return;
}
if (_ProviderInBuildCollector.kinds.contains(kind)) {
final arguments =
type.typeArguments?.arguments ?? const <TypeAnnotation>[];
creations.add(
_ProviderCreation(
kind,
arguments.isEmpty
? '?'
: arguments.first.toSource().split('<').first.trim(),
node.offset,
node.toSource(),
),
);
}
super.visitInstanceCreationExpression(node);
}
}
/// Append `ChangeNotifier` findings to [findings].
///
/// Returns a coverage note so that "0 findings" can be told apart from "the rule
/// never ran": it reports how many notifier classes and candidate mutators were
/// actually examined.
String runProviderRules({
required Project project,
required List<FileOutcome> outcomes,
required NotifierModel model,
required List<Finding> findings,
required Set<String> enabledRules,
FixPolicy policy = FixPolicy.wireUp,
@ -728,44 +57,25 @@ String runProviderRules({
return '';
}
final notifiers = <_NotifierInfo>[];
final consumedBy = <String, List<String>>{};
final declarations = <ProviderDeclaration>[];
final memberUses = <String, Set<String>>{};
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
unit.unit.accept(
_NotifierCollector(project, outcome.path, unit.lineInfo, notifiers),
);
unit.unit.accept(
_ConsumerCollector(project, outcome.path, unit.lineInfo, consumedBy),
);
unit.unit.accept(
_ProviderDeclarationCollector(
project,
outcome.path,
unit.lineInfo,
declarations,
),
);
final uses = <String>{};
unit.unit.accept(_MemberUseCollector(uses));
memberUses[project.relative(outcome.path)] = uses;
}
final notifiers = model.notifiers;
final consumedBy = model.consumedBy;
final declarations = model.declarations;
final memberUses = model.memberUses;
// A method notifies if it calls notifyListeners or calls a notifying method.
final notifyingOf = <NotifierFact, Set<String>>{};
for (final notifier in notifiers) {
final notifying = notifyingOf[notifier] = <String>{};
final stack = <String>[
for (final method in notifier.methods.values)
for (final method in notifier.methods)
if (method.directNotify) method.name,
for (final name in notifier.methods.keys)
if (_notifyWrapperName.hasMatch(name)) name,
for (final method in notifier.methods)
if (_notifyWrapperName.hasMatch(method.name)) method.name,
];
while (stack.isNotEmpty) {
final name = stack.removeLast();
if (!notifier.notifying.add(name)) continue;
for (final method in notifier.methods.values) {
if (!notifying.add(name)) continue;
for (final method in notifier.methods) {
if (method.calledNames.contains(name)) stack.add(method.name);
}
}
@ -779,23 +89,32 @@ String runProviderRules({
declaration.valueType!,
};
final notifierNames = {for (final notifier in notifiers) notifier.name};
if (listenableTypes.isNotEmpty || notifierNames.isNotEmpty) {
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
unit.unit.accept(
_ReadInBuildCollector(
project,
outcome.path,
unit.lineInfo,
listenableTypes,
notifierNames,
findings,
bool isListened(String type) =>
listenableTypes.contains(type) || notifierNames.contains(type);
for (final read in model.nonSubscribingReads) {
final type = read.type;
if (!isListened(type)) continue;
findings.add(
Finding(
rule: ruleProviderReadInBuild,
severity: PipeSeverity.warning,
confidence: 'medium',
message:
'$type is read without subscribing inside build '
'(${read.excerpt}). This widget will not rebuild when $type '
'changes, so anything it renders from that value can go stale.',
file: read.path,
line: read.line,
evidence: ['${read.path}:${read.line}: ${read.excerpt}'],
remediation:
'Use context.watch<$type>() (or Provider.of<$type>(context)) so '
'the widget subscribes — or keep read() and make sure nothing '
'rendered depends on the value.',
data: {'valueType': type, 'call': read.call, 'member': 'build'},
),
);
}
}
}
// R012 - a public getter on a consumed notifier that nothing reads: dead state
// the UI can never show. Reported once per notifier, not once per member.
@ -848,15 +167,54 @@ String runProviderRules({
// R013 - a provider constructed inside build.
if (wantCreatedInBuild) {
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
unit.unit.accept(
_ProviderInBuildCollector(
project,
outcome.path,
unit.lineInfo,
findings,
for (final build in model.createdInBuild) {
// One finding per build method, not one per provider: on a real app root
// this is a single decision (hoist them or not), and 45 findings for one
// root widget buries everything else. The claim is factual - create does
// rerun on rebuild - but whether it matters depends on how often the
// widget rebuilds, which is why this is info and not a warning.
final kinds = <String, int>{};
final valueTypes = <String>[];
for (final creation in build.creations) {
kinds.update(creation.kind, (v) => v + 1, ifAbsent: () => 1);
if (!valueTypes.contains(creation.valueType)) {
valueTypes.add(creation.valueType);
}
}
valueTypes.sort();
final summary = kinds.entries
.map((e) => '${e.key} x${e.value}')
.join(', ');
final first = build.creations.first;
findings.add(
Finding(
rule: ruleProviderCreatedInBuild,
severity: PipeSeverity.info,
confidence: 'medium',
message:
'${build.owner}.build constructs ${build.creations.length} '
'provider(s) without `.value` ($summary), so their create '
'callbacks run again if this widget rebuilds - new instances and '
'listener registrations, and any state they held is lost.',
file: build.path,
line: build.line,
evidence: [
for (final creation in build.creations.take(3))
'${build.path}:${creation.line}: '
'${creation.kind}<${creation.valueType}>',
],
remediation:
'Hoist these above the widget that rebuilds (a parent build or '
'the app root), or use the `.value` constructor with instances '
'created once. Harmless only if this widget never rebuilds.',
data: {
'owner': build.owner,
'member': 'build',
'kindCounts': summary,
'valueTypes': valueTypes,
'providerCount': build.creations.length,
'firstKind': first.kind,
},
),
);
}
@ -868,7 +226,8 @@ String runProviderRules({
final consumers = consumedBy[notifier.name] ?? const <String>[];
final isConsumed = consumers.isNotEmpty;
if (isConsumed) consumedCount++;
final inert = isConsumed && notifier.notifying.isEmpty;
final notifying = notifyingOf[notifier]!;
final inert = isConsumed && notifying.isEmpty;
// When nothing in the notifier ever notifies, one class-level finding beats
// one per mutator, so the consumer-facing rule covers it instead.
@ -894,7 +253,7 @@ String runProviderRules({
}
if (!wantNeverNotifies) continue;
for (final method in notifier.methods.values) {
for (final method in notifier.methods) {
if (method.mutations.isEmpty) continue;
// Lifecycle teardown must not notify: notifying after dispose throws, so
// a missing notify here is correct, not a bug.
@ -905,15 +264,15 @@ String runProviderRules({
.toList();
if (observable.isEmpty) continue;
candidates++;
if (notifier.notifying.contains(method.name)) continue;
if (notifying.contains(method.name)) continue;
// The UI entry points are the public methods; private helpers are an
// implementation detail of some other path.
if (method.name.startsWith('_')) continue;
// If a notifying method calls this one, the caller is responsible for
// announcing the change, so this is not a missing notification.
final calledByNotifier = notifier.methods.values.any(
final calledByNotifier = notifier.methods.any(
(caller) =>
notifier.notifying.contains(caller.name) &&
notifying.contains(caller.name) &&
caller.calledNames.contains(method.name),
);
if (calledByNotifier) continue;

View file

@ -1,13 +1,8 @@
import 'dart:convert';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';
import 'analysis.dart';
import 'errors.dart';
import 'excerpt.dart';
import 'project.dart';
import 'provider_rules.dart';
import 'state_flow.dart';
@ -183,6 +178,23 @@ class Finding {
return file ?? fingerprint;
}
/// This finding one confidence step lower, recording why.
Finding lowered(String reason) => Finding(
rule: rule,
severity: severity,
confidence: switch (confidence) {
'high' => 'medium',
_ => 'low',
},
message: message,
file: file,
line: line,
evidence: evidence,
remediation: remediation,
data: {...data, 'confidenceLowered': reason},
baselined: baselined,
);
Finding asBaselined() => Finding(
rule: rule,
severity: severity,
@ -362,10 +374,12 @@ bool _looksLoading(String variant) => RegExp(
).hasMatch(variant);
/// Build a finding per rule over an already-built state-flow graph.
///
/// Rules read only [flow]: no syntax tree, no resolved unit. Any front end that
/// can produce a [StateFlow] (or its JSON) gets every rule.
FindingReport runChecks({
required Project project,
required List<FileOutcome> outcomes,
required StateFlow flow,
ScanCoverage? coverage,
Set<String>? enabledRules,
Set<String>? baseline,
FixPolicy policy = FixPolicy.wireUp,
@ -403,19 +417,10 @@ FindingReport runChecks({
// Constructed type names per file, used to avoid reporting a handler as dead
// when its event is built somewhere and dispatched through a variable.
final constructedByFile = <String, Set<String>>{};
if (enabled.contains(ruleDeadHandler)) {
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
final collector = _ConstructionCollector();
unit.unit.accept(collector);
constructedByFile[project.relative(outcome.path)] = collector.constructed;
}
}
final constructedByFile = flow.constructedByFile;
// Snippets are already bounded excerpts; capping again keeps a site line
// bounded whatever produced the snippet.
// bounded whatever produced the snippet (another front end, say).
String site(EmissionSite emission) =>
'${emission.path}:${emission.line}: ${capExcerpt(emission.snippet)}';
@ -582,8 +587,14 @@ FindingReport runChecks({
if (enabled.contains(ruleRenderedNotEmitted)) {
for (final coverage in flow.coverages) {
if (coverage.renderedNeverEmitted.isEmpty) continue;
// An emission that names its state type belongs to that union only; one
// that does not (a Dart `emit(_helper())`) is attributed by owner, since a
// bloc owns one state type. A C++ class holds several enums, and a cast
// to one of them must not make every other one unprovable.
final ownerEmissions = flow.emissions.where(
(emission) => emission.owner == coverage.union.owner,
(emission) => emission.stateType == null
? emission.owner == coverage.union.owner
: emission.stateType == coverage.union.name,
);
final uncertain = ownerEmissions.any(
(emission) =>
@ -643,15 +654,53 @@ FindingReport runChecks({
handledByBloc.putIfAbsent(handler.bloc, () => {}).add(handler.event);
}
// A dispatch does not record which bloc it targets, so when several blocs
// share an event type, an event any of them handles is handled.
final ownersByUnion = <String, List<String>>{};
for (final union in flow.eventUnions) {
ownersByUnion.putIfAbsent(union.name, () => []).add(union.owner);
}
final reportedUnhandled = <String>{};
for (final union in flow.eventUnions) {
final variantNames = {for (final v in union.variants) v.name};
final handled = handledByBloc[union.owner] ?? <String>{};
final owners = ownersByUnion[union.name]!;
final handledByAnyOwner = <String>{
for (final owner in owners) ...?handledByBloc[owner],
};
final dispatchedHere = dispatched.keys
.where(variantNames.contains)
.toSet();
// `on<Base>` or `on<Group>` handles every subtype, so coverage follows the
// hierarchy: an event is handled when it or any ancestor up to the union
// base is registered, and a handler is live when anything at or below it
// is dispatched. Exact-name matching reported every dispatched subclass
// of a base handler as unhandled (error severity) and the base handler
// itself as dead.
final parentOf = {
for (final variant in union.variants)
if (variant.parent != null) variant.name: variant.parent!,
};
Iterable<String> selfAndAncestors(String event) sync* {
final seen = <String>{};
String? current = event;
while (current != null && seen.add(current)) {
yield current;
current = parentOf[current];
}
}
bool isHandled(String event) =>
selfAndAncestors(event).any(handledByAnyOwner.contains);
bool covers(String handler, String event) =>
selfAndAncestors(event).contains(handler);
if (enabled.contains(ruleEventWithoutHandler)) {
for (final event in dispatchedHere.difference(handled)) {
for (final event in dispatchedHere.where((e) => !isHandled(e))) {
// Reported once per shared union, not once per owning bloc.
if (!reportedUnhandled.add('${union.name}.$event')) continue;
final sites = dispatched[event]!;
findings.add(
Finding(
@ -659,7 +708,8 @@ FindingReport runChecks({
severity: PipeSeverity.error,
confidence: 'high',
message:
'$event is dispatched but ${union.owner} registers no handler '
'$event is dispatched but ${owners.join(', ')} '
'${owners.length == 1 ? 'registers' : 'register'} no handler '
'for it, so the interaction does nothing.',
file: sites.first.path,
line: sites.first.line,
@ -677,7 +727,9 @@ FindingReport runChecks({
}
if (enabled.contains(ruleDeadHandler)) {
for (final event in handled.difference(dispatchedHere)) {
for (final event in handled.where(
(h) => !dispatchedHere.any((e) => covers(h, e)),
)) {
final registration = flow.handlers.firstWhere(
(handler) => handler.bloc == union.owner && handler.event == event,
);
@ -692,7 +744,8 @@ FindingReport runChecks({
};
final constructedElsewhere = constructedByFile.entries.any(
(entry) =>
!declaredIn.contains(entry.key) && entry.value.contains(event),
!declaredIn.contains(entry.key) &&
entry.value.any((built) => covers(event, built)),
);
if (constructedElsewhere) continue;
findings.add(
@ -702,7 +755,7 @@ FindingReport runChecks({
confidence: 'high',
message:
'${union.owner} handles $event, but nothing under '
'${project.directories.join(', ')} ever dispatches or '
'${flow.directories.join(', ')} ever dispatches or '
'constructs $event.',
file: registration.path,
line: registration.line,
@ -716,7 +769,7 @@ FindingReport runChecks({
'dispatched from a directory that was not scanned (add it '
'with --dir): test-only handlers are not missing features.'
: 'Remove the dead handler once nothing outside '
'${project.directories.join(', ')} dispatches $event '
'${flow.directories.join(', ')} dispatches $event '
'(widen the scan with --dir to check), or dispatch '
'$event from the UI.',
data: {'union': union.name, 'owner': union.owner, 'event': event},
@ -741,16 +794,33 @@ FindingReport runChecks({
for (final emission in flow.emissions)
if (emission.kind == 'notifyListeners') emission.owner,
};
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
unit.unit.accept(
_LoadingResetCollector(
project,
outcome.path,
unit.lineInfo,
stateOwners,
findings,
for (final fact in flow.swallowedFailures) {
if (!stateOwners.contains(fact.owner)) continue;
findings.add(
Finding(
rule: ruleLoadingNotReset,
severity: PipeSeverity.warning,
confidence: 'high',
message:
'${fact.owner}.${fact.member} emits an in-progress state and then '
'awaits an operation whose catch clause never emits a terminal '
'state, so the UI can be left showing a spinner forever.',
file: fact.path,
line: fact.line,
evidence: ['${fact.path}:${fact.line}: ${fact.handler}'],
remediation:
'Emit a failure/timeout state in the catch (or rethrow) so the '
'loading state is always resolved.',
data: {
'owner': fact.owner,
'member': fact.member,
// A method can hold more than one swallowed catch, so the catch's
// place in its member is part of this rule's identity.
'catchSite': fact.site,
// Kept for reading version-1 baselines, whose fingerprints
// embedded it; no longer part of the fingerprint.
'catchLine': fact.line,
},
),
);
}
@ -761,25 +831,40 @@ FindingReport runChecks({
// on. An awaited call on the loading path that no catch resolves can leave the
// UI spinning forever, and this week's variant-based rule cannot see it.
if (enabled.contains(ruleLoadingFlagNotReset)) {
final flagStatesByOwner = <String, FlagState>{
for (final state in flow.flagStates) state.owner: state,
};
if (flagStatesByOwner.isNotEmpty) {
for (final outcome in outcomes) {
final unit = outcome.unit;
if (unit == null) continue;
unit.unit.accept(
_FlagLoadingCollector(
project,
outcome.path,
unit.lineInfo,
flagStatesByOwner,
findings,
for (final fact in flow.unsettledFlags) {
final fieldList = fact.fields.join(' / ');
findings.add(
Finding(
rule: ruleLoadingFlagNotReset,
severity: PipeSeverity.warning,
confidence: fact.setsFlag && !fact.guardedTrySeen ? 'high' : 'medium',
message:
'${fact.owner}.${fact.member} awaits work after putting '
'`${fact.stateType}.$fieldList` into its loading state, and no '
'catch clause surfaces a failure, so the UI can keep showing its '
'loading state after an error.',
file: fact.path,
line: fact.line,
evidence: [
'${fact.path}:${fact.line}: ${fact.owner}.${fact.member} awaits '
'with ${fact.stateType}.$fieldList in flight and no catch that '
'emits a failure state',
],
remediation:
'Wrap the awaited call in try/catch and emit a state with '
'`$fieldList` reset (or rethrow), so the loading state always '
'resolves.',
data: {
'owner': fact.owner,
'member': fact.member,
'stateType': fact.stateType,
'fields': fact.fields,
'defaultsTrue': fact.defaultsTrue,
},
),
);
}
}
}
// R008 — Riverpod: an async provider read in the UI whose error and/or
// loading state is never rendered anywhere, so a failure shows the user a
@ -836,8 +921,7 @@ FindingReport runChecks({
enabled.contains(ruleNotifierFieldUnread) ||
enabled.contains(ruleProviderCreatedInBuild)) {
final note = runProviderRules(
project: project,
outcomes: outcomes,
model: flow.notifierModel,
findings: findings,
enabledRules: enabled,
policy: policy,
@ -845,6 +929,33 @@ FindingReport runChecks({
if (note.isNotEmpty) notes.add(note);
}
// Every finding is a whole-program absence claim (never rendered, never
// dispatched, never notified), and an unresolved reference can hide the
// evidence that refutes it: an unresolved `Foo()` is a method call, not a
// construction. On a real app whose package:flutter could not be read,
// findings were reported at high confidence with no indication. pipe's
// evidence is name-based, so only a finding whose names an unresolved file
// mentions can have lost evidence there; lowering every finding for one
// stray error elsewhere would bury the signal.
final unresolved = flow.unresolved;
if (unresolved.any) {
var lowered = 0;
for (var i = 0; i < findings.length; i++) {
if (!_identityNames(findings[i]).any(unresolved.names.contains)) continue;
findings[i] = findings[i].lowered('unresolved-references');
lowered++;
}
notes.add(
'${unresolved.files.length} of ${unresolved.analysed} analysed file(s) '
'have unresolved references (first: ${unresolved.first}). An '
'unresolved `Foo()` '
'is not seen as a construction, so evidence in those files can be '
'missed: $lowered finding(s) naming something they mention had their '
'confidence lowered one step. Run `pipe index` and resolve the project '
'(`pub get`, a readable SDK) for full confidence.',
);
}
final resolved = baseline == null
? findings
: [
@ -864,14 +975,11 @@ FindingReport runChecks({
});
return FindingReport(
root: project.root,
root: flow.root,
findings: resolved,
suppressed: suppressed,
notes: notes,
coverage: ScanCoverage.of(
discovered: project.dartFiles.length,
outcomes: outcomes,
),
coverage: coverage,
);
}
@ -1003,427 +1111,27 @@ Set<String> decodeBaseline(String content) {
};
}
class _ConstructionCollector extends RecursiveAstVisitor<void> {
final Set<String> constructed = {};
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
constructed.add(node.constructorName.type.name.lexeme);
super.visitInstanceCreationExpression(node);
}
}
class _TryCollector extends RecursiveAstVisitor<void> {
final List<TryStatement> tries = [];
@override
void visitTryStatement(TryStatement node) {
tries.add(node);
super.visitTryStatement(node);
}
// Nested functions are analysed on their own.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
class _CalledNames extends RecursiveAstVisitor<void> {
final Set<String> names = {};
@override
void visitMethodInvocation(MethodInvocation node) {
final target = node.target;
if (target == null || target is ThisExpression) {
names.add(node.methodName.name);
}
super.visitMethodInvocation(node);
}
@override
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
final function = node.function;
if (function is SimpleIdentifier) names.add(function.name);
super.visitFunctionExpressionInvocation(node);
}
}
/// Finds awaited work on a loading-flag path that no catch clause resolves.
class _FlagLoadingCollector extends RecursiveAstVisitor<void> {
_FlagLoadingCollector(
this.project,
this.path,
this.lineInfo,
this.flagStatesByOwner,
this.findings,
);
final Project project;
final String path;
final LineInfo lineInfo;
final Map<String, FlagState> flagStatesByOwner;
final List<Finding> findings;
final List<String> _classStack = [];
/// Methods invoked from a constructor body: for those, a default-true loading
/// flag is in effect even though the method never sets it itself.
final Map<String, Set<String>> _constructorCalls = {};
static final RegExp _stateAssign = RegExp(r'(^|[^.\w])state\s*=(?!=)');
static final RegExp _throwing = RegExp(r'\b(rethrow|throw)\b');
static final RegExp _errorish = RegExp(
r'(Error|Failure|Fail|Cancel|Retry|Recover|Reset|Clear)',
caseSensitive: false,
);
static bool _surfacesFailure(String source) {
if (source.contains('emit(') || source.contains('notifyListeners(')) {
return true;
}
if (_stateAssign.hasMatch(source)) return true;
if (_throwing.hasMatch(source)) return true;
return source.contains('add(') && _errorish.hasMatch(source);
}
@override
void visitClassDeclaration(ClassDeclaration node) {
final name = node.namePart.typeName.lexeme;
final calls = <String>{};
for (final member in node.body.members) {
if (member is! ConstructorDeclaration) continue;
final collector = _CalledNames();
member.body.accept(collector);
calls.addAll(collector.names);
}
if (calls.isNotEmpty) _constructorCalls[name] = calls;
_classStack.add(name);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMixinDeclaration(MixinDeclaration node) {
_classStack.add(node.name.lexeme);
super.visitMixinDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
_analyze(node.body, node.name.lexeme);
super.visitMethodDeclaration(node);
}
@override
void visitFunctionExpression(FunctionExpression node) {
_analyze(node.body, '<closure>');
super.visitFunctionExpression(node);
}
void _analyze(FunctionBody? body, String member) {
if (body == null) return;
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
final flag = flagStatesByOwner[owner];
if (flag == null) return;
// Lifecycle teardown never renders a loading state.
if (member == 'close' || member == 'dispose') return;
final source = body.toSource();
// Only methods that actually put the loading flag into the true state can
// leave the UI spinning. A method that merely runs in a class whose state
// has such a flag (a repository write, a teardown call) cannot.
final setsFlagTrue = RegExp(
'(${flag.fields.join('|')})\\s*:\\s*true',
).hasMatch(source);
final inheritsInitialFlag =
flag.defaultsTrue &&
(_constructorCalls[owner]?.contains(member) ?? false);
if (!setsFlagTrue && !inheritsInitialFlag) return;
// Guarded when an awaited try has a catch that surfaces the failure.
final tries = _TryCollector();
body.accept(tries);
var awaitedTryGuarded = false;
var awaitedTrySeen = false;
for (final statement in tries.tries) {
final awaitedInTry = _HasAwait();
statement.body.accept(awaitedInTry);
if (!awaitedInTry.found) continue;
awaitedTrySeen = true;
for (final catchClause in statement.catchClauses) {
if (_surfacesFailure(catchClause.body.toSource())) {
awaitedTryGuarded = true;
}
}
}
if (awaitedTryGuarded) return;
// Nothing awaited at all means there is no failure path to leave dangling.
final awaitProbe = _HasAwait();
body.accept(awaitProbe);
if (!awaitProbe.found) return;
final fieldList = flag.fields.join(' / ');
findings.add(
Finding(
rule: ruleLoadingFlagNotReset,
severity: PipeSeverity.warning,
confidence: setsFlagTrue && !awaitedTrySeen ? 'high' : 'medium',
message:
'$owner.$member awaits work after putting '
'`${flag.name}.$fieldList` into its loading state, and no catch '
'clause surfaces a failure, so '
'the UI can keep showing its loading state after an error.',
file: project.relative(path),
line: lineInfo.getLocation(body.offset).lineNumber,
evidence: [
'${project.relative(path)}:'
'${lineInfo.getLocation(body.offset).lineNumber}: '
'$owner.$member awaits with ${flag.name}.$fieldList in flight and '
'no catch that emits a failure state',
],
remediation:
'Wrap the awaited call in try/catch and emit a state with '
'`$fieldList` reset (or rethrow), so the loading state always '
'resolves.',
data: {
'owner': owner,
'member': member,
'stateType': flag.name,
'fields': flag.fields,
'defaultsTrue': flag.defaultsTrue,
},
),
);
}
}
/// Scans a single function body for in-flight loading emits and top-level
/// awaited try statements.
class _BodyScan extends RecursiveAstVisitor<void> {
final List<int> loadingEmits = [];
final List<TryStatement> topLevelTries = [];
static final RegExp _loading = RegExp(
r'(Loading|Processing|Submitting|Saving|Creating|Updating|InProgress)',
);
int _tryDepth = 0;
void _maybeLoading(AstNode node, List<Argument> arguments) {
if (arguments.isEmpty) return;
final first = arguments.first;
if (first is! Expression) return;
if (_loading.hasMatch(first.toSource())) loadingEmits.add(node.offset);
}
@override
void visitMethodInvocation(MethodInvocation node) {
if (node.methodName.name == 'emit') {
_maybeLoading(node, node.argumentList.arguments);
}
super.visitMethodInvocation(node);
}
@override
void visitFunctionExpressionInvocation(FunctionExpressionInvocation node) {
final function = node.function;
if (function is SimpleIdentifier && function.name == 'emit') {
_maybeLoading(node, node.argumentList.arguments);
}
super.visitFunctionExpressionInvocation(node);
}
@override
void visitTryStatement(TryStatement node) {
final isTopLevel = _tryDepth == 0;
_tryDepth++;
if (isTopLevel) topLevelTries.add(node);
super.visitTryStatement(node);
_tryDepth--;
}
// Nested functions are analysed in their own right by _analyze; descending
// into them here would report the same catch twice.
@override
void visitFunctionExpression(FunctionExpression node) {}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {}
}
class _CatchCollector extends RecursiveAstVisitor<void> {
final List<CatchClause> clauses = [];
@override
void visitCatchClause(CatchClause node) {
clauses.add(node);
super.visitCatchClause(node);
}
}
class _HasAwait extends RecursiveAstVisitor<void> {
bool found = false;
@override
void visitAwaitExpression(AwaitExpression node) {
found = true;
super.visitAwaitExpression(node);
}
}
class _LoadingResetCollector extends RecursiveAstVisitor<void> {
_LoadingResetCollector(
this.project,
this.path,
this.lineInfo,
this.stateOwners,
this.findings,
);
final Project project;
final String path;
final LineInfo lineInfo;
final Set<String> stateOwners;
final List<Finding> findings;
final List<String> _classStack = [];
/// Enclosing named members (method, function, constructor), innermost last,
/// so a catch in a closure is placed relative to the member that holds it.
final List<({String name, AstNode node})> _members = [];
static final RegExp _stateAssign = RegExp(r'(^|[^.\w])state\s*=(?!=)');
static final RegExp _throwing = RegExp(r'\b(rethrow|throw)\b');
static final RegExp _errorish = RegExp(
r'(Error|Failure|Fail|Cancel|Retry|Recover|Reset|Clear)',
caseSensitive: false,
);
@override
void visitClassDeclaration(ClassDeclaration node) {
_classStack.add(node.namePart.typeName.lexeme);
super.visitClassDeclaration(node);
_classStack.removeLast();
}
@override
void visitMixinDeclaration(MixinDeclaration node) {
_classStack.add(node.name.lexeme);
super.visitMixinDeclaration(node);
_classStack.removeLast();
}
@override
void visitMethodDeclaration(MethodDeclaration node) {
_members.add((name: node.name.lexeme, node: node));
_analyze(node.body, node.name.lexeme);
super.visitMethodDeclaration(node);
_members.removeLast();
}
@override
void visitFunctionDeclaration(FunctionDeclaration node) {
_members.add((name: node.name.lexeme, node: node));
super.visitFunctionDeclaration(node);
_members.removeLast();
}
@override
void visitConstructorDeclaration(ConstructorDeclaration node) {
final name = node.name;
_members.add((
name: name == null ? 'new' : 'new ${name.lexeme}',
node: node,
));
super.visitConstructorDeclaration(node);
_members.removeLast();
}
@override
void visitFunctionExpression(FunctionExpression node) {
_analyze(node.body, '<closure>');
super.visitFunctionExpression(node);
}
/// `member#index`: the catch's position among every catch clause of its
/// enclosing member, in source order. Unlike its line, this does not change
/// when code outside that member moves.
String _catchSite(CatchClause clause, FunctionBody body) {
final enclosing = _members.isEmpty ? null : _members.last;
final collector = _CatchCollector();
(enclosing?.node ?? body).accept(collector);
final index = collector.clauses.indexWhere(
(candidate) => identical(candidate, clause),
);
return '${enclosing?.name ?? '<closure>'}#$index';
}
void _analyze(FunctionBody? body, String member) {
if (body == null) return;
final owner = _classStack.isEmpty ? '<top>' : _classStack.last;
if (!stateOwners.contains(owner)) return;
final scan = _BodyScan();
body.accept(scan);
if (scan.loadingEmits.isEmpty) return;
for (final statement in scan.topLevelTries) {
if (statement.catchClauses.isEmpty) continue;
final probe = _HasAwait();
statement.body.accept(probe);
if (!probe.found) continue;
for (final catchClause in statement.catchClauses) {
final source = catchClause.body.toSource();
final emits =
source.contains('emit(') || source.contains('notifyListeners(');
final assignsState = _stateAssign.hasMatch(source);
final rethrows = _throwing.hasMatch(source);
final dispatchesFailure =
source.contains('add(') && _errorish.hasMatch(source);
if (emits || assignsState || rethrows || dispatchesFailure) continue;
findings.add(
Finding(
rule: ruleLoadingNotReset,
severity: PipeSeverity.warning,
confidence: 'high',
message:
'$owner.$member emits an in-progress state and then awaits an '
'operation whose catch clause never emits a terminal state, so '
'the UI can be left showing a spinner forever.',
file: project.relative(path),
line: lineInfo.getLocation(catchClause.offset).lineNumber,
evidence: [
'${project.relative(path)}:'
'${lineInfo.getLocation(catchClause.offset).lineNumber}: '
'${_catchExcerpt(catchClause)}',
],
remediation:
'Emit a failure/timeout state in the catch (or rethrow) so the '
'loading state is always resolved.',
data: {
'owner': owner,
'member': member,
// A method can hold more than one swallowed catch, so the catch's
// place in its member is part of this rule's identity.
'catchSite': _catchSite(catchClause, body),
// Kept for reading version-1 baselines, whose fingerprints
// embedded it; no longer part of the fingerprint.
'catchLine': lineInfo.getLocation(catchClause.offset).lineNumber,
},
),
);
}
}
}
/// The catch body as bounded evidence: string literals elided, one line,
/// capped (see [sourceExcerpt]).
static String _catchExcerpt(CatchClause clause) {
final excerpt = sourceExcerpt(clause.body);
return excerpt.isEmpty ? '<empty catch>' : excerpt;
/// The names a finding makes a claim about: the values of its identity keys.
Set<String> _identityNames(Finding finding) {
const keys = [
'union',
'variant',
'variants',
'group',
'event',
'provider',
'owner',
'member',
'members',
'stateType',
'valueType',
'valueTypes',
];
final names = <String>{};
for (final key in keys) {
final value = finding.data[key];
if (value is String) names.add(value);
if (value is List) names.addAll(value.whereType<String>());
}
return names;
}

View file

@ -45,6 +45,44 @@ String? _normalizedDirectory(String? path) {
return _isDirectory(path) ? p.normalize(p.absolute(path)) : null;
}
/// The SDK root holding a `<sdk>/bin/dart` executable, or null when the
/// executable does not sit inside a Dart SDK.
///
/// The executable's own directory is `<sdk>/bin`, which never contains
/// `lib/libraries.json`; checking it meant this fallback could never succeed.
String? sdkFromExecutable(String executable) {
final root = p.dirname(p.dirname(p.normalize(executable)));
return _isDartSdk(root) ? root : null;
}
/// Fails with an actionable error when the chosen SDK cannot be read.
///
/// Existence is not enough: macOS privacy protection (for example a Flutter SDK
/// under ~/Documents) lets `stat` succeed while every open fails, so discovery
/// accepted the SDK and the analyzer later died with a raw FileSystemException.
void requireReadableSdk(SdkResolution resolution) {
final sdk = resolution.sdkPath;
if (sdk == null) return;
for (final probe in [
p.join(sdk, 'version'),
p.join(sdk, 'lib', 'libraries.json'),
]) {
final file = File(probe);
if (!file.existsSync()) continue;
try {
file.openSync().closeSync();
} on FileSystemException catch (error) {
throw PipeException(
'the Dart SDK at $sdk (from ${resolution.source}) cannot be read: '
'${error.osError?.message ?? error.message}',
remedy:
'grant this process access to that directory, or pass '
'--sdk <path to a readable dart-sdk>',
);
}
}
}
/// Given a Flutter root, return its bundled Dart SDK if one is present.
String? dartSdkInFlutterRoot(String flutterRoot) {
final candidate = p.join(flutterRoot, 'bin', 'cache', 'dart-sdk');
@ -144,8 +182,8 @@ SdkResolution resolveSdk({String? explicit, String? flutterRootHint}) {
);
}
final runtimeSdk = p.dirname(p.normalize(Platform.resolvedExecutable));
if (_isDartSdk(runtimeSdk)) {
final runtimeSdk = sdkFromExecutable(Platform.resolvedExecutable);
if (runtimeSdk != null) {
return SdkResolution(
sdkPath: runtimeSdk,
flutterRoot: null,

View file

@ -4,7 +4,10 @@ import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';
import 'analysis.dart';
import 'dart_facts.dart';
import 'dart_provider_facts.dart';
import 'excerpt.dart';
import 'facts.dart';
import 'project.dart';
/// How a state union is expressed in source.
@ -27,16 +30,22 @@ enum UnionStyle {
/// Something that can be matched on the state type.
class StateVariant {
StateVariant({required this.name, this.declaredIn, this.note});
StateVariant({required this.name, this.declaredIn, this.note, this.parent});
final String name;
final String? declaredIn;
final String? note;
/// The direct supertype this variant was enumerated through: the union base
/// or an intermediate group. Null for enum constants and freezed redirects.
/// Rules use it so a handler on a base or group type covers its subtypes.
final String? parent;
Map<String, Object?> toJson() => {
'name': name,
if (declaredIn != null) 'declaredIn': declaredIn,
if (note != null) 'note': note,
if (parent != null) 'parent': parent,
};
}
@ -81,6 +90,7 @@ class StateUnion {
'ownerKind': ownerKind,
if (eventType != null) 'eventType': eventType,
'variants': [for (final variant in variants) variant.toJson()],
if (uiReadsBaseFields) 'uiReadsBaseFields': true,
};
}
@ -95,6 +105,7 @@ class EmissionSite {
required this.confidence,
required this.variants,
required this.snippet,
this.stateType,
});
final String path;
@ -102,6 +113,11 @@ class EmissionSite {
final String owner;
final String member;
/// The state type this emission produces, when the front end knows it even
/// though the variant is unknown (a C++ `static_cast<State>(n)`). Null means
/// "attribute by owner", which is right when an owner has one state type.
final String? stateType;
/// `emit`, `assign` or `notifyListeners`.
final String kind;
@ -119,6 +135,7 @@ class EmissionSite {
'confidence': confidence,
if (variants.isNotEmpty) 'variants': variants,
'snippet': snippet,
if (stateType != null) 'stateType': stateType,
};
}
@ -349,9 +366,26 @@ class StateFlow {
required this.ownerReferences,
required this.flagStates,
required this.riverpod,
});
this.directories = const ['lib'],
this.coverage,
this.constructedByFile = const {},
this.swallowedFailures = const [],
this.unsettledFlags = const [],
UnresolvedSummary? unresolved,
NotifierModel? notifierModel,
}) : unresolved =
unresolved ?? UnresolvedSummary(files: const [], analysed: 0),
notifierModel = notifierModel ?? NotifierModel();
final String root;
/// Directories the front end scanned, so a claim like "nothing dispatches X"
/// can say where it looked.
final List<String> directories;
/// Which files the front end examined, so a report built from this model
/// (in process or read back from JSON) can say how complete it is.
final ScanCoverage? coverage;
final List<StateUnion> unions;
final List<EventUnion> eventUnions;
final List<EmissionSite> emissions;
@ -371,8 +405,26 @@ class StateFlow {
/// Riverpod providers and their consumption sites.
final RiverpodModel riverpod;
/// Project-relative file -> type names constructed in it.
final Map<String, Set<String>> constructedByFile;
/// Busy variant emitted, then an awaited failure swallowed (loading-not-reset).
final List<SwallowedFailure> swallowedFailures;
/// Loading flag left set across unguarded awaited work
/// (loading-flag-not-reset).
final List<UnsettledFlag> unsettledFlags;
/// Files whose references did not resolve, and the names they mention.
final UnresolvedSummary unresolved;
/// ChangeNotifier / provider facts.
final NotifierModel notifierModel;
Map<String, Object?> toJson() => {
'root': root,
'directories': directories,
if (coverage != null) 'scanCoverage': coverage!.toJson(),
'totals': {
'unions': unions.length,
'variants': unions.fold<int>(
@ -395,6 +447,14 @@ class StateFlow {
'ownerReferences': ownerReferences,
'flagStates': [for (final state in flagStates) state.toJson()],
'riverpod': riverpod.toJson(),
'constructedByFile': {
for (final entry in constructedByFile.entries)
entry.key: entry.value.toList()..sort(),
},
'swallowedFailures': [for (final fact in swallowedFailures) fact.toJson()],
'unsettledFlags': [for (final fact in unsettledFlags) fact.toJson()],
'unresolved': unresolved.toJson(),
'notifiers': notifierModel.toJson(),
};
/// Human summary, ordered by the most suspicious signal first.
@ -473,6 +533,7 @@ class _TypeInfo {
required this.isSealed,
required this.enumConstants,
required this.factoryRedirects,
this.namedRedirects = const {},
this.loadingFields = const [],
this.loadingDefaultsTrue = false,
this.fields = const [],
@ -488,6 +549,10 @@ class _TypeInfo {
final List<String> enumConstants;
final List<String> factoryRedirects;
/// Factory constructor name (`''` for the unnamed one) to its redirect
/// target, so `DocState.loading()` can be credited to the variant it builds.
final Map<String, String> namedRedirects;
/// Loading-style boolean fields declared on the type.
final List<String> loadingFields;
@ -604,8 +669,14 @@ class _TypeCollector extends RecursiveAstVisitor<void> {
/// Read freezed-style `factory X.y(...) = Z;` redirect targets from source.
/// Parsed textually because the AST accessor is experimental.
static List<String> _factoryRedirects(NodeList<ClassMember> members) {
final redirects = <String>[];
static List<String> _factoryRedirects(NodeList<ClassMember> members) =>
_namedFactoryRedirects(members).values.toList();
/// Same parse, keyed by constructor name (`''` for the unnamed factory).
static Map<String, String> _namedFactoryRedirects(
NodeList<ClassMember> members,
) {
final redirects = <String, String>{};
for (final member in members) {
if (member is! ConstructorDeclaration) continue;
if (member.factoryKeyword == null) continue;
@ -614,7 +685,9 @@ class _TypeCollector extends RecursiveAstVisitor<void> {
r'=\s*([A-Za-z_$][A-Za-z0-9_$.]*)',
).firstMatch(declaration);
final target = match?.group(1);
if (target != null) redirects.add(_baseName(target));
if (target != null) {
redirects[member.name?.lexeme ?? ''] = _baseName(target);
}
}
return redirects;
}
@ -639,6 +712,7 @@ class _TypeCollector extends RecursiveAstVisitor<void> {
isSealed: node.sealedKeyword != null,
enumConstants: const [],
factoryRedirects: _factoryRedirects(node.body.members),
namedRedirects: _namedFactoryRedirects(node.body.members),
loadingFields: loadingFields,
loadingDefaultsTrue: loadingDefaultsTrue,
fields: _declaredFields(node.body.members),
@ -669,9 +743,16 @@ class _TypeCollector extends RecursiveAstVisitor<void> {
}
class _EmissionCollector extends RecursiveAstVisitor<void> {
_EmissionCollector(this.project, this.path, this.lineInfo, this.sites);
_EmissionCollector(
this.project,
this.path,
this.lineInfo,
this.sites,
this.redirects,
);
final Project project;
final Map<String, String> redirects;
final String path;
final LineInfo lineInfo;
final List<EmissionSite> sites;
@ -726,11 +807,11 @@ class _EmissionCollector extends RecursiveAstVisitor<void> {
/// Arguments arrive as [Argument] wrappers; positional arguments are also
/// [Expression]s, which is how we get at the emitted value.
static _Extraction _fromArguments(List<Argument> arguments) {
_Extraction _fromArguments(List<Argument> arguments) {
if (arguments.isEmpty) return _Extraction(const [], 'unknown');
final argument = arguments.first;
return argument is Expression
? _extractVariants(argument)
? _extractVariants(argument, redirects)
: _Extraction(const [], 'unknown');
}
@ -770,7 +851,7 @@ class _EmissionCollector extends RecursiveAstVisitor<void> {
@override
void visitAssignmentExpression(AssignmentExpression node) {
if (node.leftHandSide.toSource().trim() == 'state') {
final extraction = _extractVariants(node.rightHandSide);
final extraction = _extractVariants(node.rightHandSide, redirects);
sites.add(
EmissionSite(
path: project.relative(path),
@ -798,12 +879,15 @@ class _Extraction {
}
class _VariantFinder extends RecursiveAstVisitor<void> {
_VariantFinder(this.redirects);
final Map<String, String> redirects;
final List<String> found = [];
var sawCopyWith = false;
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
final name = node.constructorName.type.name.lexeme;
final name = _createdVariant(node, redirects);
if (!found.contains(name)) found.add(name);
super.visitInstanceCreationExpression(node);
}
@ -815,15 +899,32 @@ class _VariantFinder extends RecursiveAstVisitor<void> {
}
}
_Extraction _extractVariants(Expression expression) {
/// The variant an instance creation builds. A freezed-style factory
/// (`const factory DocState.loading() = DocLoading;`) builds its redirect
/// target, not the base type it is called on, so `DocState.loading()` is
/// credited to `DocLoading`. Recording `DocState` meant no freezed emission ever
/// matched its variant.
String _createdVariant(
InstanceCreationExpression node,
Map<String, String> redirects,
) {
final type = node.constructorName.type.name.lexeme;
final constructor = node.constructorName.name?.name ?? '';
return redirects['$type.$constructor'] ?? type;
}
_Extraction _extractVariants(
Expression expression,
Map<String, String> redirects,
) {
if (expression is InstanceCreationExpression) {
return _Extraction([expression.constructorName.type.name.lexeme], 'exact');
return _Extraction([_createdVariant(expression, redirects)], 'exact');
}
if (expression is MethodInvocation &&
expression.methodName.name == 'copyWith') {
return _Extraction(const [], 'copyWith');
}
final finder = _VariantFinder();
final finder = _VariantFinder(redirects);
expression.accept(finder);
if (finder.sawCopyWith && finder.found.isEmpty) {
return _Extraction(const [], 'copyWith');
@ -1118,7 +1219,9 @@ class _MentionCollector extends RecursiveAstVisitor<void> {
final current = queue.removeLast();
if (!visited.add(current)) continue;
for (final type in bySupertype[current] ?? const <_TypeInfo>[]) {
variants.add(StateVariant(name: type.name, declaredIn: type.file));
variants.add(
StateVariant(name: type.name, declaredIn: type.file, parent: current),
);
queue.add(type.name);
}
}
@ -1188,9 +1291,16 @@ class _HandlerCollector extends RecursiveAstVisitor<void> {
/// Records `add(Event())` dispatch sites.
class _DispatchCollector extends RecursiveAstVisitor<void> {
_DispatchCollector(this.project, this.path, this.lineInfo, this.dispatches);
_DispatchCollector(
this.project,
this.path,
this.lineInfo,
this.dispatches,
this.redirects,
);
final Project project;
final Map<String, String> redirects;
final String path;
final LineInfo lineInfo;
final List<EventDispatch> dispatches;
@ -1202,7 +1312,7 @@ class _DispatchCollector extends RecursiveAstVisitor<void> {
if (arguments.isNotEmpty) {
final argument = arguments.first;
if (argument is Expression) {
final extraction = _extractVariants(argument);
final extraction = _extractVariants(argument, redirects);
for (final variant in extraction.variants) {
dispatches.add(
EventDispatch(
@ -1434,6 +1544,45 @@ class _RiverpodCollector extends RecursiveAstVisitor<void> {
/// comparing node identity, because analyzer 14 exposes dual AST origins
/// where `parent.target == child` can fail for the same logical node.
static ({bool error, bool loading, bool readsValue}) _handling(AstNode node) {
final scope = _consumer(node);
final sources = [scope.toSource()];
// `final v = ref.watch(p); ... v.when(...)`: the handling lives on the
// variable's uses, which the parent climb alone never reached, so a stored
// value was neither handled nor read as far as this analysis could see.
final declaration = scope.parent;
if (declaration is VariableDeclaration) {
final body = declaration.thisOrAncestorOfType<FunctionBody>();
if (body != null) {
final uses = _VariableUses(declaration.name.lexeme);
body.accept(uses);
for (final use in uses.found) {
if (use.offset == declaration.name.offset) continue;
sources.add(_consumer(use).toSource());
}
}
}
final source = sources.join('\n');
// `error:` / `loading:` may appear either on an AsyncValue `.when(...)` or
// as a callback on `ref.listen(..., error: ...)`, and a switch handles them
// with `AsyncError()` / `AsyncLoading()` patterns - the form pipe's own
// remediation recommends, which was not recognised.
final error =
RegExp(r'\berror\s*:').hasMatch(source) ||
source.contains('.hasError') ||
RegExp(r'\bAsyncError\b').hasMatch(source);
final loading =
RegExp(r'\bloading\s*:').hasMatch(source) ||
source.contains('.isLoading') ||
RegExp(r'\bAsyncLoading\b').hasMatch(source);
final readsValue = RegExp(
r'\.(asData|value|valueOrNull|requireValue)\b',
).hasMatch(source);
return (error: error, loading: loading, readsValue: readsValue);
}
/// The outermost expression [node]'s value flows into, widened to a switch
/// statement when that expression is what it switches on.
static AstNode _consumer(AstNode node) {
AstNode scope = node;
while (true) {
final parent = scope.parent;
@ -1443,18 +1592,9 @@ class _RiverpodCollector extends RecursiveAstVisitor<void> {
}
break;
}
final source = scope.toSource();
// `error:` / `loading:` may appear either on an AsyncValue `.when(...)` or
// as a callback on `ref.listen(..., error: ...)`, so match either form.
final error =
RegExp(r'\berror\s*:').hasMatch(source) || source.contains('.hasError');
final loading =
RegExp(r'\bloading\s*:').hasMatch(source) ||
source.contains('.isLoading');
final readsValue = RegExp(
r'\.(asData|value|valueOrNull|requireValue)\b',
).hasMatch(source);
return (error: error, loading: loading, readsValue: readsValue);
final parent = scope.parent;
if (parent is SwitchStatement) return parent;
return scope;
}
@override
@ -1488,6 +1628,21 @@ class _RiverpodCollector extends RecursiveAstVisitor<void> {
}
}
/// Identifiers named [name]: the uses of a local variable (shadowing is not
/// modelled, which can only add sources, never hide one).
class _VariableUses extends RecursiveAstVisitor<void> {
_VariableUses(this.name);
final String name;
final List<SimpleIdentifier> found = [];
@override
void visitSimpleIdentifier(SimpleIdentifier node) {
if (node.name == name) found.add(node);
super.visitSimpleIdentifier(node);
}
}
/// Build the state-flow graph for a resolved project.
StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
final types = <_TypeInfo>[];
@ -1517,6 +1672,12 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
for (final type in types)
if (type.isEnum) type.name: type,
};
// `Type.constructor` -> the class a redirecting factory actually builds.
final redirects = <String, String>{
for (final type in types)
for (final entry in type.namedRedirects.entries)
'${type.name}.${entry.key}': entry.value,
};
// Bloc/cubit owners and their state types.
final blocState = <String, String>{};
@ -1654,13 +1815,13 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
);
}
// Event unions: the `E` of `Bloc<E, S>` plus its subclass events.
// Event unions: the `E` of `Bloc<E, S>` plus its subclass events. One per
// owning bloc: blocs that share an event type each register their own
// handlers, and keeping only the first owner ignored every other bloc's.
final eventUnions = <EventUnion>[];
final seenEvents = <String>{};
for (final entry in blocEvent.entries) {
final eventType = entry.value;
if (eventType == null) continue;
if (!seenEvents.add(eventType)) continue;
final enumeration = _enumerateVariants(
eventType,
byName,
@ -1686,14 +1847,41 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
final dispatches = <EventDispatch>[];
final riverpodProviders = <RiverpodProvider>[];
final riverpodSites = <RiverpodSite>[];
final constructedByFile = <String, Set<String>>{};
final swallowedFailures = <SwallowedFailure>[];
final unsettledFlags = <UnsettledFlag>[];
final flagStatesByOwner = <String, FlagState>{
for (final state in flagStates) state.owner: state,
};
for (final entry in unitsByPath.entries) {
final result = entry.value;
final unit = result.unit;
final lineInfo = result.lineInfo;
unit.accept(_EmissionCollector(project, entry.key, lineInfo, emissions));
final relative = project.relative(entry.key);
final constructions = ConstructionCollector();
unit.accept(constructions);
constructedByFile[relative] = constructions.constructed;
unit.accept(
SwallowedFailureCollector(relative, lineInfo, swallowedFailures),
);
if (flagStatesByOwner.isNotEmpty) {
unit.accept(
UnsettledFlagCollector(
relative,
lineInfo,
flagStatesByOwner,
unsettledFlags,
),
);
}
unit.accept(
_EmissionCollector(project, entry.key, lineInfo, emissions, redirects),
);
unit.accept(_RenderCollector(project, entry.key, lineInfo, renders));
unit.accept(_HandlerCollector(project, entry.key, lineInfo, handlers));
unit.accept(_DispatchCollector(project, entry.key, lineInfo, dispatches));
unit.accept(
_DispatchCollector(project, entry.key, lineInfo, dispatches, redirects),
);
unit.accept(
_RiverpodCollector(
project,
@ -1770,6 +1958,11 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
return StateFlow(
root: project.root,
directories: project.directories,
coverage: ScanCoverage.of(
discovered: project.dartFiles.length,
outcomes: outcomes,
),
unions: unions,
eventUnions: eventUnions,
emissions: emissions,
@ -1780,5 +1973,209 @@ StateFlow buildStateFlow(Project project, List<FileOutcome> outcomes) {
ownerReferences: ownerReferences,
flagStates: flagStates,
riverpod: RiverpodModel(providers: riverpodProviders, sites: riverpodSites),
constructedByFile: constructedByFile,
swallowedFailures: swallowedFailures,
unsettledFlags: unsettledFlags,
unresolved: _unresolvedSummary(project, outcomes),
notifierModel: collectNotifierModel(project, unitsByPath),
);
}
UnresolvedSummary _unresolvedSummary(
Project project,
List<FileOutcome> outcomes,
) {
final found = findUnresolvedReferences(outcomes);
final first = found.first;
return UnresolvedSummary(
files: [for (final file in found.files) project.relative(file)],
analysed: found.analysed,
first: first?.replaceFirst('${project.root}/', ''),
names: found.names,
);
}
// ---------------------------------------------------------------------------
// Decoding
// ---------------------------------------------------------------------------
/// Rebuild a [StateFlow] from its [StateFlow.toJson] form.
///
/// This is the seam for other front ends: anything that writes this JSON (a
/// separate process for another language, say) can be checked by every rule
/// with `runChecks(flow: stateFlowFromJson(json))`. Computed members (`totals`,
/// coverage differences, Riverpod `gaps`, `isAsync`) are ignored on input and
/// recomputed.
StateFlow stateFlowFromJson(Map<String, Object?> json) {
List<Map<String, Object?>> objects(Object? value) => [
for (final item in (value as List?) ?? const [])
item as Map<String, Object?>,
];
List<String> strings(Object? value) => [
...((value as List?) ?? const []).cast<String>(),
];
StateVariant variant(Map<String, Object?> v) => StateVariant(
name: v['name']! as String,
declaredIn: v['declaredIn'] as String?,
note: v['note'] as String?,
parent: v['parent'] as String?,
);
final unions = [
for (final u in objects(json['unions']))
StateUnion(
name: u['name']! as String,
declaredIn: u['declaredIn']! as String,
style: UnionStyle.values.byName(u['style']! as String),
owner: u['owner']! as String,
ownerKind: u['ownerKind']! as String,
eventType: u['eventType'] as String?,
variants: [for (final v in objects(u['variants'])) variant(v)],
uiReadsBaseFields: (u['uiReadsBaseFields'] as bool?) ?? false,
),
];
StateUnion unionFor(String name, String owner) =>
unions.firstWhere((union) => union.name == name && union.owner == owner);
return StateFlow(
root: json['root']! as String,
directories: strings(json['directories'] ?? const ['lib']),
coverage: json['scanCoverage'] == null
? null
: ScanCoverage.fromJson(json['scanCoverage']! as Map<String, Object?>),
unions: unions,
eventUnions: [
for (final u in objects(json['eventUnions']))
EventUnion(
name: u['name']! as String,
declaredIn: u['declaredIn']! as String,
owner: u['owner']! as String,
variants: [for (final v in objects(u['variants'])) variant(v)],
),
],
emissions: [
for (final e in objects(json['emissions']))
EmissionSite(
path: e['path']! as String,
line: e['line']! as int,
owner: e['owner']! as String,
member: e['member']! as String,
kind: e['kind']! as String,
confidence: e['confidence']! as String,
variants: strings(e['variants']),
snippet: e['snippet']! as String,
stateType: e['stateType'] as String?,
),
],
renders: [
for (final r in objects(json['renders']))
RenderSite(
path: r['path']! as String,
line: r['line']! as int,
widget: r['widget']! as String,
stateType: r['stateType']! as String,
branches: [
for (final b in objects(r['branches']))
RenderBranch(
variant: b['variant']! as String,
line: b['line']! as int,
trivial: b['trivial']! as bool,
negated: (b['negated'] as bool?) ?? false,
),
],
),
],
handlers: [
for (final h in objects(json['handlers']))
EventHandler(
bloc: h['bloc']! as String,
event: h['event']! as String,
path: h['path']! as String,
line: h['line']! as int,
),
],
dispatches: [
for (final d in objects(json['dispatches']))
EventDispatch(
event: d['event']! as String,
path: d['path']! as String,
line: d['line']! as int,
confidence: d['confidence']! as String,
snippet: d['snippet']! as String,
),
],
coverages: [
for (final c in objects(json['coverage']))
UnionCoverage(
union: unionFor(c['union']! as String, c['owner']! as String),
declared: {...strings(c['declared'])},
emitted: {...strings(c['emitted'])},
initial: {...strings(c['initial'])},
rendered: {...strings(c['rendered'])},
renderedInBuilder: {...strings(c['renderedInBuilder'])},
trivialInBuilder: {...strings(c['trivialInBuilder'])},
renderSites: c['renderSites']! as int,
emissionSites: c['emissionSites']! as int,
),
],
ownerReferences: {
for (final entry
in ((json['ownerReferences'] as Map?) ?? const {}).entries)
entry.key as String: strings(entry.value),
},
flagStates: [
for (final f in objects(json['flagStates']))
FlagState(
name: f['name']! as String,
owner: f['owner']! as String,
fields: strings(f['fields']),
defaultsTrue: f['defaultsTrue']! as bool,
),
],
riverpod: RiverpodModel(
providers: [
for (final p in objects((json['riverpod'] as Map?)?['providers']))
RiverpodProvider(
name: p['name']! as String,
kind: p['kind']! as String,
declaredIn: p['declaredIn']! as String,
line: p['line']! as int,
valueType: p['valueType'] as String?,
),
],
sites: [
for (final site in objects((json['riverpod'] as Map?)?['sites']))
RiverpodSite(
provider: site['provider']! as String,
path: site['path']! as String,
line: site['line']! as int,
watches: site['watch']! as bool,
handlesError: site['handlesError']! as bool,
handlesLoading: site['handlesLoading']! as bool,
readsValue: site['readsValue']! as bool,
),
],
),
constructedByFile: {
for (final entry
in ((json['constructedByFile'] as Map?) ?? const {}).entries)
entry.key as String: {...strings(entry.value)},
},
swallowedFailures: [
for (final f in objects(json['swallowedFailures']))
SwallowedFailure.fromJson(f),
],
unsettledFlags: [
for (final f in objects(json['unsettledFlags']))
UnsettledFlag.fromJson(f),
],
unresolved: json['unresolved'] == null
? null
: UnresolvedSummary.fromJson(
json['unresolved']! as Map<String, Object?>,
),
notifierModel: json['notifiers'] == null
? null
: NotifierModel.fromJson(json['notifiers']! as Map<String, Object?>),
);
}

View file

@ -17,12 +17,8 @@ void main() {
await analysis.dispose();
});
FindingReport reportWith(Set<String>? baseline) => runChecks(
project: analysis.project,
outcomes: analysis.run.outcomes,
flow: analysis.flow,
baseline: baseline,
);
FindingReport reportWith(Set<String>? baseline) =>
runChecks(flow: analysis.flow, baseline: baseline);
test('encodes a baseline whose fingerprints cover every finding', () {
final encoded = encodeBaseline(

95
test/cpp_front_test.dart Normal file
View file

@ -0,0 +1,95 @@
import 'dart:convert';
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:test/test.dart';
/// Contract for the experimental C++ front end (tool/cpp_front.py): it writes a
/// state model, and the unchanged rules must fire on each positive control in
/// test/fixtures/cpp_state and stay silent on each negative one.
///
/// Skipped, not passed, when Homebrew LLVM (libclang + its Python bindings) or
/// python3 is unavailable.
void main() {
final llvm = Platform.environment['PIPE_LLVM'] ?? '/opt/homebrew/opt/llvm';
final available =
File('$llvm/lib/libclang.dylib').existsSync() &&
Process.runSync('python3', ['--version']).exitCode == 0;
late FindingReport report;
setUpAll(() async {
if (!available) return;
final root = Directory.systemTemp.createTempSync('pipe-cpp');
Directory('${root.path}/src').createSync();
File(
'test/fixtures/cpp_state/src/engine.cpp',
).copySync('${root.path}/src/engine.cpp');
File('${root.path}/compile_commands.json').writeAsStringSync(
jsonEncode([
{
'directory': root.path,
'file': 'src/engine.cpp',
'arguments': ['c++', '-std=c++20', '-c', 'src/engine.cpp'],
},
]),
);
final model = '${root.path}/model.json';
final result = Process.runSync('python3', [
'tool/cpp_front.py',
root.path,
'--include',
'src',
'-o',
model,
]);
expect(result.exitCode, 0, reason: '${result.stderr}');
expect('${result.stderr}', contains('0 clang error(s)'));
report = runChecks(
flow: stateFlowFromJson(
jsonDecode(File(model).readAsStringSync()) as Map<String, Object?>,
),
);
});
Iterable<Finding> hits(String rule) =>
report.findings.where((finding) => finding.rule == rule);
test('an enum produced but never examined is reported', () {
expect(
hits(ruleEmittedNotRendered).map((f) => f.data['union']),
contains('fx::Unread'),
);
}, skip: available ? false : 'Homebrew LLVM / python3 not available');
test('an enumerator examined but never produced is a dead branch', () {
final ghost = hits(
ruleRenderedNotEmitted,
).where((f) => f.data['variant'] == 'Ghost');
expect(ghost, hasLength(1));
expect(ghost.single.file, 'src/engine.cpp');
expect(ghost.single.line, isNotNull);
}, skip: available ? false : 'Homebrew LLVM / python3 not available');
test('a cast to one enum does not mute "never emitted" for another', () {
// Casted::Y is produced only through static_cast, so it is unprovable
// and suppressed. Phase lives in the same class and must still be judged.
expect(
hits(ruleRenderedNotEmitted).map((f) => f.data['union']),
isNot(contains('fx::Casted')),
);
expect(report.suppressed.where((s) => s.contains('fx::Phase')), isEmpty);
}, skip: available ? false : 'Homebrew LLVM / python3 not available');
test(
'a swallowed failure on the method\'s own busy state fires, and only it',
() {
// startGuarded resolves the state, startNested's try is a nested local
// fallback, and Reporter::run sets a field of a local event object.
expect(hits(ruleLoadingNotReset).map((f) => f.data['member']).toList(), [
'startSwallowed',
]);
},
skip: available ? false : 'Homebrew LLVM / python3 not available',
);
}

View file

@ -0,0 +1,84 @@
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:test/test.dart';
/// The fixture copied with [padding] blank lines inserted at the top of every
/// Dart source, so every finding moves down without any semantic change.
String _shiftedFixture(int padding) {
final from = Directory('test/fixtures/stuck_loading');
final to = Directory.systemTemp.createTempSync('pipe-shifted');
for (final entity in from.listSync(recursive: true)) {
final relative = entity.path.substring(from.path.length + 1);
final target = '${to.path}/$relative';
if (entity is Directory) {
Directory(target).createSync(recursive: true);
} else if (entity is File) {
File(target).createSync(recursive: true);
if (entity.path.endsWith('.dart')) {
// After any `library;` / import header would change nothing; a comment
// block at the very top shifts every line in the file.
File(target).writeAsStringSync(
'${List.filled(padding, '//').join('\n')}\n'
'${entity.readAsStringSync()}',
);
} else {
File(target).writeAsBytesSync(entity.readAsBytesSync());
}
}
}
return to.path;
}
Future<List<Finding>> _findings(String root) async {
final analysis = await analyzeProject(
await loadProject(root),
concurrency: 1,
);
try {
return analysis.report.findings;
} finally {
await analysis.dispose();
}
}
void main() {
late List<Finding> original;
late List<Finding> shifted;
setUpAll(() async {
original = await _findings('test/fixtures/stuck_loading');
shifted = await _findings(_shiftedFixture(7));
});
test('moving code down a file does not change any fingerprint', () {
// A baseline must keep accepting a finding when unrelated edits move it.
// Any fingerprint carrying a line number resurrected accepted findings as
// new on the next check.
expect(
shifted.map((f) => f.line),
isNot(equals(original.map((f) => f.line))),
reason: 'the control must actually move the findings',
);
final before = {for (final f in original) f.fingerprint: f.rule};
final after = {for (final f in shifted) f.fingerprint: f.rule};
final moved = {
for (final entry in before.entries)
if (!after.containsKey(entry.key)) entry.key: entry.value,
};
expect(moved, isEmpty, reason: 'fingerprints that depend on position');
});
test('no two findings share a fingerprint', () {
// A collision makes the baseline keep one finding and silently drop the
// other, so a second real defect can never be reported as new.
final counts = <String, int>{};
for (final f in original) {
counts[f.fingerprint] = (counts[f.fingerprint] ?? 0) + 1;
}
expect({
for (final e in counts.entries)
if (e.value > 1) e.key: e.value,
}, isEmpty);
});
}

104
test/fixtures/cpp_state/src/engine.cpp vendored Normal file
View file

@ -0,0 +1,104 @@
// Fixture for tool/cpp_front.py: each case is a positive or negative control
// for a rule run on the C++ state model (see test/cpp_front_test.dart).
namespace fx {
// Ghost is compared against but never produced: rendered-not-emitted.
enum class Phase { Idle, Starting, Running, Failed, Ghost };
// Produced, never compared or switched on: emitted-not-rendered.
enum class Unread { A, B };
// Y is compared against and produced only through a cast from an int, so the
// rule must suppress itself rather than call the branch dead.
enum class Casted { X, Y };
void mayThrow();
class Engine {
public:
// Negative: the catch resolves the busy state.
void startGuarded() {
phase_ = Phase::Starting;
try {
mayThrow();
phase_ = Phase::Running;
} catch (...) {
phase_ = Phase::Failed;
}
}
// Positive: busy state set, then a top-level try whose catch swallows the
// failure, so Starting can outlive it (loading-not-reset).
void startSwallowed() {
phase_ = Phase::Starting;
try {
mayThrow();
phase_ = Phase::Running;
} catch (...) {
}
}
// Negative: a nested try is a local fallback, not the method's failure path.
void startNested() {
phase_ = Phase::Starting;
if (ready_) {
try {
mayThrow();
} catch (...) {
}
}
phase_ = Phase::Running;
}
bool ghost() const { return phase_ == Phase::Ghost; }
bool idle() const {
switch (phase_) {
case Phase::Idle:
return true;
case Phase::Starting:
case Phase::Running:
case Phase::Failed:
return false;
default:
return false;
}
}
void setUnread() {
unread_ = Unread::A;
unread_ = Unread::B;
}
void load(int n) { casted_ = static_cast<Casted>(n); }
bool isY() const { return casted_ == Casted::Y; }
private:
bool ready_ = false;
Phase phase_ = Phase::Idle;
Unread unread_ = Unread::A;
Casted casted_ = Casted::X;
};
// Negative: `ev.stage` is a field of a local event object handed to a callback,
// not state Reporter holds, so its try cannot leave Reporter stuck.
struct Event {
Phase stage;
};
void notify(const Event&);
class Reporter {
public:
void run() {
Event ev;
ev.stage = Phase::Starting;
notify(ev);
try {
mayThrow();
} catch (...) {
}
}
};
} // namespace fx

View file

@ -0,0 +1,127 @@
import 'bloc.dart';
// --- a handler registered on the event BASE --------------------------------
//
// `on<SyncEvent>` with a switch inside handles every subclass. Matching handlers
// to dispatches by exact name would report every dispatched subclass as
// unhandled (error severity) and the base handler itself as dead. This is the
// regression guard for handler coverage following the event hierarchy.
sealed class SyncEvent {
const SyncEvent();
}
class SyncStarted extends SyncEvent {
const SyncStarted();
}
class SyncCancelled extends SyncEvent {
const SyncCancelled();
}
/// An intermediate group: `on<RetryEvent>` covers [RetryNow] only.
abstract class RetryEvent extends SyncEvent {
const RetryEvent();
}
class RetryNow extends RetryEvent {
const RetryNow();
}
class SyncState {
const SyncState();
}
class SyncBloc extends Bloc<SyncEvent, SyncState> {
SyncBloc() : super(const SyncState());
void register() {
on<SyncEvent>((event, emit) {
switch (event) {
case SyncStarted():
case SyncCancelled():
case RetryEvent():
emit(const SyncState());
}
});
}
void start() => add(const SyncStarted());
}
// A second bloc whose only handler is on an intermediate group, so the rule must
// credit dispatches of the group's subclasses to it, and still report a sibling
// outside the group.
sealed class UploadEvent {
const UploadEvent();
}
abstract class UploadRetry extends UploadEvent {
const UploadRetry();
}
class UploadRetryNow extends UploadRetry {
const UploadRetryNow();
}
/// Dispatched, and outside the handled `UploadRetry` group: truly unhandled.
class UploadAbandoned extends UploadEvent {
const UploadAbandoned();
}
class UploadBloc extends Bloc<UploadEvent, SyncState> {
UploadBloc() : super(const SyncState());
void register() {
on<UploadRetry>((event, emit) => emit(const SyncState()));
}
void retry() => add(const UploadRetryNow());
void abandon() => add(const UploadAbandoned());
}
// --- two blocs sharing one event type ---------------------------------------
//
// Keeping only the first owner of an event type ignored the second bloc's
// handlers: an event only the second bloc handles was reported unhandled (error
// severity), and a dead handler in the second bloc was never seen.
sealed class ShareEvent {
const ShareEvent();
}
class SharePressed extends ShareEvent {
const SharePressed();
}
class ShareDismissed extends ShareEvent {
const ShareDismissed();
}
/// Handled only by [ShareBetaBloc], and never dispatched: dead there.
class ShareExpired extends ShareEvent {
const ShareExpired();
}
class ShareAlphaBloc extends Bloc<ShareEvent, SyncState> {
ShareAlphaBloc() : super(const SyncState());
void register() {
on<SharePressed>((event, emit) => emit(const SyncState()));
}
void press() => add(const SharePressed());
}
class ShareBetaBloc extends Bloc<ShareEvent, SyncState> {
ShareBetaBloc() : super(const SyncState());
void register() {
on<ShareDismissed>((event, emit) => emit(const SyncState()));
on<ShareExpired>((event, emit) => emit(const SyncState()));
}
void dismiss() => add(const ShareDismissed());
}

View file

@ -6,6 +6,10 @@ import 'bloc.dart';
/// that the variant-based rules cannot see.
class FlagEvent {}
class FlagRefresh extends FlagEvent {}
class FlagReload extends FlagEvent {}
class FlagState {
const FlagState({this.items = const <int>[], this.loading = true});
@ -43,4 +47,33 @@ class FlagCubit extends Bloc<FlagEvent, FlagState> {
Future<void> refreshQuietly() async {
await Future<void>.delayed(Duration.zero);
}
/// GOOD: the flag, the await and the guarding catch all live in a closure,
/// which is analysed on its own. The enclosing method sets nothing and awaits
/// nothing itself, so reporting it would be a false positive.
void scheduleGuarded() {
_later(() async {
emit(state.copyWith(loading: true));
try {
await Future<void>.delayed(Duration.zero);
} catch (error) {
emit(state.copyWith(loading: false));
}
});
}
void _later(Future<void> Function() work) {}
/// BAD, twice: two unguarded handlers in one class. Each is its own finding
/// with its own fingerprint, not one `<closure>` that hides the other.
void registerHandlers() {
on<FlagRefresh>((event, emit) async {
emit(state.copyWith(loading: true));
await Future<void>.delayed(Duration.zero);
});
on<FlagReload>((event, emit) async {
emit(state.copyWith(loading: true));
await Future<void>.delayed(Duration.zero);
});
}
}

View file

@ -0,0 +1,41 @@
import 'bloc.dart';
// --- a freezed-style union --------------------------------------------------
//
// freezed declares variants as redirecting factories on the base, and the bloc
// emits through them: `emit(const DocState.loading())` builds a DocLoading.
// Recording the base type (`DocState`) meant no emission ever matched a variant,
// so every variant the UI renders was reported as never emitted. The variant
// classes are written by hand here, as the generated file would declare them,
// because this fixture has no dependencies and no codegen.
class DocEvent {}
sealed class DocState {
const DocState();
const factory DocState.loading() = DocLoading;
const factory DocState.ready() = DocReady;
/// Declared, rendered, and never emitted: the positive control that proves
/// redirect resolution did not simply suppress the rule.
const factory DocState.stale() = DocStale;
}
class DocLoading extends DocState {
const DocLoading();
}
class DocReady extends DocState {
const DocReady();
}
class DocStale extends DocState {
const DocStale();
}
class DocBloc extends Bloc<DocEvent, DocState> {
DocBloc() : super(const DocState.loading());
void load() => emit(const DocState.ready());
}

View file

@ -0,0 +1,17 @@
// UI for freezed_style.dart, kept in its own file because type tests in a file
// that declares a bloc do not count as UI.
import 'bloc.dart';
import 'freezed_style.dart';
class DocView {
const DocView();
Object build() => BlocBuilder<DocBloc, DocState>(
builder: (context, state) => switch (state) {
DocLoading() => 'loading',
DocReady() => 'ready',
DocStale() => 'stale',
},
);
}

View file

@ -12,6 +12,8 @@ class AsyncData<T> {
class AsyncValue<T> {
const AsyncValue();
T? get value => null;
AsyncData<T>? get asData => null;
bool get hasError => false;
@ -24,6 +26,14 @@ class AsyncValue<T> {
throw UnimplementedError();
}
class AsyncError<T> extends AsyncValue<T> {
const AsyncError();
}
class AsyncLoading<T> extends AsyncValue<T> {
const AsyncLoading();
}
class FutureProvider<T> {
const FutureProvider(this.create);
@ -70,6 +80,50 @@ class SubscriptionView {
error: (error, stack) => -2,
);
/// GOOD: pattern-matches AsyncError and AsyncLoading in a switch expression,
/// the form the remediation itself recommends.
int renderPatterned() => switch (ref.watch(patternedProvider)) {
AsyncError() => -2,
AsyncLoading() => -1,
_ => 0,
};
/// GOOD: the same in a switch statement.
int renderSwitchStatement() {
switch (ref.watch(switchedProvider)) {
case AsyncError():
return -2;
case AsyncLoading():
return -1;
default:
return 0;
}
}
/// GOOD: the watched value is stored in a variable and then handled.
int renderStored() {
final count = ref.watch(storedProvider);
return count.when(
data: (value) => value,
loading: () => -1,
error: (error, stack) => -2,
);
}
/// Direct value reads of the three GOOD providers above. Without a site that
/// reads the value, a provider is never reported whatever its handling, so
/// those controls would prove nothing.
int? peekPatterned() => ref.watch(patternedProvider).value;
int? peekSwitched() => ref.watch(switchedProvider).value;
int? peekStored() => ref.watch(storedProvider).value;
/// BAD: stored in a variable and then read without handling, so following
/// the variable must not silence the rule.
int? renderStoredUnhandled() {
final count = ref.watch(storedUnhandledProvider);
return count.value;
}
/// Not a render: a listener that mirrors the value into a side effect (for
/// example an analytics user property) draws nothing, so ignoring loading
/// and error here leaves no blank or stale screen.
@ -80,3 +134,8 @@ class SubscriptionView {
});
}
}
final patternedProvider = FutureProvider<int>((ref) async => 1);
final switchedProvider = FutureProvider<int>((ref) async => 1);
final storedProvider = FutureProvider<int>((ref) async => 1);
final storedUnhandledProvider = FutureProvider<int>((ref) async => 1);

View file

@ -30,18 +30,26 @@ void main() {
.where((finding) => finding.rule == ruleLoadingFlagNotReset)
.toList();
// loadGuarded resets the flag in its catch and refreshQuietly never sets it,
// so exactly one finding should survive.
expect(hits, hasLength(1));
expect(hits.single.data['member'], 'loadUnguarded');
expect(hits.single.data['stateType'], 'FlagState');
expect(hits.single.severity, PipeSeverity.warning);
expect(hits.single.confidence, 'high');
expect(hits.single.evidence, isNotEmpty);
expect(hits.single.remediation, isNotNull);
// loadGuarded resets the flag in its catch, refreshQuietly never sets it,
// and scheduleGuarded's closure guards itself, so only the unguarded method
// and the two deliberately unguarded `on<>` handlers survive.
expect(hits.map((hit) => hit.data['member']).toSet(), {
'loadUnguarded',
'on<FlagRefresh>',
'on<FlagReload>',
});
expect(hits, hasLength(3));
final unguarded = hits.singleWhere(
(hit) => hit.data['member'] == 'loadUnguarded',
);
expect(unguarded.data['stateType'], 'FlagState');
expect(unguarded.severity, PipeSeverity.warning);
expect(unguarded.confidence, 'high');
expect(unguarded.evidence, isNotEmpty);
expect(unguarded.remediation, isNotNull);
// The flag reads as one code span, `FlagState.loading`, not as
// `FlagState` immediately followed by a second span.
expect(hits.single.message, contains('`FlagState.'));
expect(unguarded.message, contains('`FlagState.'));
});
test('flag findings carry no false positive for guarded code', () {

View file

@ -205,9 +205,8 @@ void main() {
// counts beside it) counted only new ones, so a fully baselined run read
// "20 findings ({error: 0, warning: 0, info: 0})" over "## Findings (0)".
final baselined = runChecks(
project: analysis.project,
outcomes: analysis.run.outcomes,
flow: analysis.flow,
coverage: analysis.report.coverage,
baseline: {
for (final finding in analysis.report.findings) finding.fingerprint,
},

116
test/model_cli_test.dart Normal file
View file

@ -0,0 +1,116 @@
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:pipe/src/cli.dart';
import 'package:test/test.dart';
/// Collects what a command writes to stdout.
class _CapturedStdout implements Stdout {
final StringBuffer buffer = StringBuffer();
@override
void write(Object? object) => buffer.write(object);
@override
void writeln([Object? object = '']) => buffer.writeln(object);
@override
dynamic noSuchMethod(Invocation invocation) => null;
}
Future<(int?, String)> _run(List<String> args) async {
final out = _CapturedStdout();
final code = await IOOverrides.runZoned(
() => PipeRunner().run(args),
stdout: () => out,
);
return (code, out.buffer.toString());
}
const String _fixture = 'test/fixtures/stuck_loading';
void main() {
late String model;
setUpAll(() async {
final dir = Directory.systemTemp.createTempSync('pipe-model');
model = '${dir.path}/model.json';
final (code, json) = await _run([
'state',
_fixture,
'--format',
'json',
'-c',
'1',
]);
expect(code, 0);
File(model).writeAsStringSync(json);
});
test('check --model reports exactly what a source check reports', () async {
// The front-end seam as a separate process would use it: the model goes
// through a file, and the rules must not notice.
final (_, fromSource) = await _run([
'check',
_fixture,
'--format',
'json',
'-c',
'1',
]);
final (_, fromModel) = await _run([
'check',
'--model',
model,
'--format',
'json',
]);
expect(fromModel, fromSource);
});
test('check --model honours --rule and --fail-on', () async {
final (code, output) = await _run([
'check',
'--model',
model,
'--rule',
ruleEventWithoutHandler,
'--fail-on',
'error',
'--format',
'json',
]);
expect(code, 1, reason: 'event-without-handler fires at error severity');
expect(output, contains(ruleEventWithoutHandler));
expect(output, isNot(contains(ruleDeadHandler)));
});
test('check --model refuses --write-baseline', () async {
final target = '${File(model).parent.path}/baseline.json';
await expectLater(
_run(['check', '--model', model, '--write-baseline', target]),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('--model'),
),
),
);
expect(File(target).existsSync(), isFalse);
});
test('check --model rejects a file that is not a model', () async {
final dir = File(model).parent.path;
File('$dir/array.json').writeAsStringSync('[1, 2]');
// An object missing required fields must be a clear error, not a crash.
File('$dir/partial.json').writeAsStringSync('{"unions": [{"name": 1}]}');
for (final bad in ['array.json', 'partial.json']) {
await expectLater(
_run(['check', '--model', '$dir/$bad']),
throwsA(isA<PipeException>()),
reason: bad,
);
}
});
}

View file

@ -19,12 +19,8 @@ void main() {
await analysis.dispose();
});
FindingReport reportWith(FixPolicy policy) => runChecks(
project: analysis.project,
outcomes: analysis.run.outcomes,
flow: analysis.flow,
policy: policy,
);
FindingReport reportWith(FixPolicy policy) =>
runChecks(flow: analysis.flow, policy: policy);
String briefWith(FixPolicy policy) => buildGuidance(
root: '/root',

View file

@ -44,17 +44,24 @@ void main() {
.where((f) => f.rule == ruleRiverpodAsyncGap)
.toList();
expect(hits, hasLength(1));
expect(hits.single.data['provider'], 'unhandledProvider');
// The two deliberate gaps: a direct read and a read through a variable.
expect(hits.map((f) => f.data['provider']).toSet(), {
'unhandledProvider',
'storedUnhandledProvider',
});
expect(hits, hasLength(2));
final unhandled = hits.singleWhere(
(f) => f.data['provider'] == 'unhandledProvider',
);
expect(
hits.single.data['missing'],
unhandled.data['missing'],
containsAll(<String>['error', 'loading']),
);
// A non-bool value type keeps this at high confidence and warning severity.
expect(hits.single.confidence, 'high');
expect(hits.single.severity, PipeSeverity.warning);
expect(hits.single.evidence, isNotEmpty);
expect(hits.single.remediation, isNotNull);
expect(unhandled.confidence, 'high');
expect(unhandled.severity, PipeSeverity.warning);
expect(unhandled.evidence, isNotEmpty);
expect(unhandled.remediation, isNotNull);
});
test('the handled provider produces no gap', () {

View file

@ -32,7 +32,13 @@ const Map<String, List<_Id>> _negatives = {
// PatternState HAS render sites and every variant it renders is emitted.
// ItemState would be vacuous: it has no render sites, so it could never be
// reported regardless of the emission logic.
ruleRenderedNotEmitted: [('union', 'PatternState')],
// DocLoading/DocReady are emitted through freezed-style redirecting factories
// (`DocState.loading()`), which must be credited to the redirect target.
ruleRenderedNotEmitted: [
('union', 'PatternState'),
('variant', 'DocLoading'),
('variant', 'DocReady'),
],
// GuardedCubit emits a loading variant and awaits inside a try whose catch
// resolves it, so reporting it would be a false positive. FlagCubit would be
// vacuous: it has no variant union and is skipped outright.
@ -40,19 +46,47 @@ const Map<String, List<_Id>> _negatives = {
// loadGuarded resolves the flag, refreshQuietly never sets it, and `_warmUp`
// is constructor-invoked on a flag defaulting to FALSE, so nothing is in
// flight.
// scheduleGuarded's closure sets the flag and guards its own await, and
// registerHandlers only registers closures: neither enclosing method is in
// flight itself. Every closure gets a real name, never `<closure>`.
ruleLoadingFlagNotReset: [
('member', 'loadGuarded'),
('member', 'refreshQuietly'),
('member', '_warmUp'),
('member', 'scheduleGuarded'),
('member', 'registerHandlers'),
('member', '<closure>'),
],
// HandledTap is handled AND dispatched, so it is the control that proves both
// event rules actually consult the handler and dispatch information.
// NeverDispatched (never handled) and UnhandledTap (never handled) were
// vacuous.
ruleEventWithoutHandler: [('event', 'HandledTap')],
ruleDeadHandler: [('event', 'HandledTap')],
// SyncStarted/UploadRetryNow are handled through `on<SyncEvent>` and
// `on<UploadRetry>`; those base and group handlers are live because a
// subtype is dispatched (base_event.dart).
ruleEventWithoutHandler: [
('event', 'HandledTap'),
('event', 'SyncStarted'),
('event', 'UploadRetryNow'),
// Handled by ShareBetaBloc, which shares ShareEvent with ShareAlphaBloc.
('event', 'ShareDismissed'),
],
ruleDeadHandler: [
('event', 'HandledTap'),
('event', 'SyncEvent'),
('event', 'UploadRetry'),
],
// handledProvider covers all AsyncValue cases.
ruleRiverpodAsyncGap: [('provider', 'handledProvider')],
// The others are handled with AsyncError/AsyncLoading patterns (switch
// expression and statement) or through a stored variable, and each is also
// read directly elsewhere, so a gap would be reported if the handling were
// missed.
ruleRiverpodAsyncGap: [
('provider', 'handledProvider'),
('provider', 'patternedProvider'),
('provider', 'switchedProvider'),
('provider', 'storedProvider'),
],
// `bump` notifies; `setQuiet` mutates but a notifying method calls it.
ruleNotifierNeverNotifies: [('member', 'bump'), ('member', 'setQuiet')],
// LiveNotifier notifies, so its Consumer can rebuild.
@ -96,6 +130,64 @@ void main() {
}
}
test('dead-handler: every bloc sharing an event type is checked', () {
// Only the first owner of ShareEvent used to be kept, so ShareBetaBloc's
// handlers were never examined.
final owners = {
for (final finding in findings)
if (finding.rule == ruleDeadHandler &&
finding.data['event'] == 'ShareExpired')
finding.data['owner'],
};
expect(owners, {'ShareBetaBloc'});
});
test('rendered-not-emitted: redirect resolution does not mute the rule', () {
// DocStale is rendered and never emitted, so crediting factory calls to
// their redirect targets must leave it reported.
expect(valuesOf(ruleRenderedNotEmitted, 'variant'), contains('DocStale'));
});
test(
'riverpod-async-gap: a value stored in a variable is still followed',
() {
// The parent climb stopped at the variable declaration, so a stored value
// read with `.value` was invisible to the rule.
expect(
valuesOf(ruleRiverpodAsyncGap, 'provider'),
contains('storedUnhandledProvider'),
);
},
);
test('event-without-handler: a group handler does not cover a sibling', () {
// UploadAbandoned sits outside the handled UploadRetry group, so following
// the hierarchy must not over-credit it.
expect(
valuesOf(ruleEventWithoutHandler, 'event'),
contains('UploadAbandoned'),
);
});
test('loading-flag-not-reset: two handlers in one class stay distinct', () {
// As `<closure>` they shared one fingerprint, so a baseline kept only one.
final hits = findings
.where(
(finding) =>
finding.rule == ruleLoadingFlagNotReset &&
finding.data['owner'] == 'FlagCubit',
)
.toList();
expect(
hits.map((finding) => finding.data['member']),
containsAll(<String>['on<FlagRefresh>', 'on<FlagReload>']),
);
expect(
hits.map((finding) => finding.fingerprint).toSet(),
hasLength(hits.length),
);
});
for (final entry in _positives.entries) {
test('${entry.key}: fires on the positive control', () {
final (key, value) = entry.value;

61
test/sdk_test.dart Normal file
View file

@ -0,0 +1,61 @@
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:pipe/src/sdk.dart';
import 'package:test/test.dart';
/// A directory laid out like a Dart SDK: `bin/dart`, `lib/libraries.json`,
/// `version`.
Directory _fakeSdk(String label) {
final sdk = Directory.systemTemp.createTempSync('pipe-$label');
File('${sdk.path}/version').writeAsStringSync('3.13.0\n');
File('${sdk.path}/lib/libraries.json')
..createSync(recursive: true)
..writeAsStringSync('{}');
File('${sdk.path}/bin/dart').createSync(recursive: true);
return sdk;
}
void main() {
test('an SDK that exists but cannot be read is a clear error', () {
// macOS privacy protection (a Flutter SDK under ~/Documents) lets stat
// succeed while every open fails, so discovery accepted the SDK and the
// analyzer later died with a raw FileSystemException stack trace.
final sdk = _fakeSdk('unreadable-sdk');
final version = File('${sdk.path}/version');
Process.runSync('chmod', ['000', version.path]);
try {
version.readAsStringSync();
markTestSkipped('chmod 000 does not restrict this process');
return;
} on FileSystemException {
// Expected: the restriction is real, so the check below is meaningful.
}
final resolution = resolveSdk(explicit: sdk.path);
expect(
() => requireReadableSdk(resolution),
throwsA(
isA<PipeException>()
.having((e) => e.message, 'message', contains(sdk.path))
.having((e) => e.remedy, 'remedy', contains('--sdk')),
),
);
});
test('a readable SDK passes the check', () {
final sdk = _fakeSdk('readable-sdk');
expect(
() => requireReadableSdk(resolveSdk(explicit: sdk.path)),
returnsNormally,
);
});
test('the running dart binary maps to its SDK root, not to bin/', () {
// dirname(<sdk>/bin/dart) is <sdk>/bin, which never contains
// lib/libraries.json, so this fallback could never succeed.
final sdk = _fakeSdk('runtime-sdk');
expect(sdkFromExecutable('${sdk.path}/bin/dart'), sdk.path);
expect(sdkFromExecutable('${sdk.path}/version'), isNull);
});
}

View file

@ -13,7 +13,7 @@ void main() {
project = await loadProject(fixturePath);
run = await resolveProject(project, concurrency: 1);
flow = buildStateFlow(project, run.outcomes);
report = runChecks(project: project, outcomes: run.outcomes, flow: flow);
report = runChecks(flow: flow);
});
tearDownAll(() async {
@ -103,11 +103,14 @@ void main() {
final hits = report.findings
.where((finding) => finding.rule == ruleEventWithoutHandler)
.toList();
// ItemBloc registers handlers for both of its events, so the only report
// must be the deliberate gap fixture.
expect(hits, hasLength(1));
expect(hits.single.data['event'], 'UnhandledTap');
expect(hits.single.data['owner'], 'GapBloc');
// ItemBloc registers handlers for both of its events, and SyncBloc/UploadBloc
// cover their other events through base and group handlers, so the only
// reports must be the two deliberate gaps.
expect(
{for (final hit in hits) '${hit.data['owner']}.${hit.data['event']}'},
{'GapBloc.UnhandledTap', 'UploadBloc.UploadAbandoned'},
);
expect(hits, hasLength(2));
});
test('every finding carries evidence', () {

View file

@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:pipe/pipe.dart';
import 'package:test/test.dart';
/// The state-map seam: rules read only the model, so a model that went through
/// JSON - as one written by a separate front end would - must produce exactly
/// the same report as the in-process model. If a rule quietly depended on
/// something the JSON does not carry, this is where it shows.
void main() {
late ProjectAnalysis analysis;
setUpAll(() async {
analysis = await analyzeProject(
await loadProject('test/fixtures/stuck_loading'),
concurrency: 1,
);
});
tearDownAll(() async {
await analysis.dispose();
});
StateFlow throughJson(StateFlow flow) => stateFlowFromJson(
jsonDecode(jsonEncode(flow.toJson())) as Map<String, Object?>,
);
test('the model survives JSON unchanged', () {
final once = jsonEncode(analysis.flow.toJson());
final twice = jsonEncode(throughJson(analysis.flow).toJson());
expect(twice, once);
});
test('rules on the decoded model report exactly the same findings', () {
final direct = jsonEncode(runChecks(flow: analysis.flow).toJson());
final decoded = jsonEncode(
runChecks(flow: throughJson(analysis.flow)).toJson(),
);
expect(decoded, direct);
// Not vacuous: every rule fires somewhere on the fixture.
final rules = runChecks(flow: analysis.flow).findings.map((f) => f.rule);
expect(rules.toSet(), containsAll(allRules));
});
}

109
test/subset_scan_test.dart Normal file
View file

@ -0,0 +1,109 @@
import 'dart:convert';
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:pipe/src/cli.dart';
import 'package:test/test.dart';
/// Collects what a command writes to stdout.
class _CapturedStdout implements Stdout {
final StringBuffer buffer = StringBuffer();
@override
void write(Object? object) => buffer.write(object);
@override
void writeln([Object? object = '']) => buffer.writeln(object);
@override
dynamic noSuchMethod(Invocation invocation) => null;
}
Future<(int?, String)> _run(List<String> args) async {
final out = _CapturedStdout();
final code = await IOOverrides.runZoned(
() => PipeRunner().run(args),
stdout: () => out,
);
return (code, out.buffer.toString());
}
const String _fixture = 'test/fixtures/stuck_loading';
void main() {
late String baseline;
setUpAll(() async {
final dir = Directory.systemTemp.createTempSync('pipe-subset');
baseline = '${dir.path}/baseline.json';
final (code, _) = await _run([
'check',
_fixture,
'--write-baseline',
baseline,
'-c',
'1',
]);
expect(code, 0);
});
test('verify --rule does not call other rules\' findings resolved', () async {
// Only dead-handler ran, so accepted findings from every other rule were
// unchecked, not fixed. Comparing against the whole baseline reported each
// of them as RESOLVED - a false claim about someone's fixes.
final entries = decodeBaselineEntries(File(baseline).readAsStringSync());
expect(
entries.map((entry) => entry.rule).toSet(),
hasLength(greaterThan(1)),
reason: 'the control needs findings from more than the selected rule',
);
final (_, output) = await _run([
'verify',
_fixture,
'--baseline',
baseline,
'--rule',
ruleDeadHandler,
'--format',
'json',
'-c',
'1',
]);
final report = jsonDecode(output) as Map<String, Object?>;
expect(report['resolved'], 0);
expect(report['new'], 0);
expect(
report['baselineTotal'],
entries.where((entry) => entry.rule == ruleDeadHandler).length,
);
});
for (final narrowing in [
['--rule', ruleDeadHandler],
['--limit', '3'],
]) {
test('--write-baseline refuses ${narrowing.first}', () async {
final target = '${File(baseline).parent.path}/subset.json';
await expectLater(
_run([
'check',
_fixture,
...narrowing,
'--write-baseline',
target,
'-c',
'1',
]),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('complete scan'),
),
),
);
expect(File(target).existsSync(), isFalse);
});
}
}

View file

@ -72,6 +72,36 @@ void main() {
);
});
test('--write-baseline refuses a partially readable project', () async {
// The unread file's findings would be missing from the accepted set, so the
// next complete scan would report them all as new.
final root = unreadableCorpus('write-partial', 'lib/item_bloc.dart');
if (root == null) {
markTestSkipped('chmod 000 does not restrict this process');
return;
}
final baseline = '$root/baseline.json';
await expectLater(
PipeRunner().run([
'check',
root,
'--write-baseline',
baseline,
'-c',
'1',
]),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('complete scan'),
),
),
);
expect(File(baseline).existsSync(), isFalse);
});
test('recall keeps evaluating cases whose evidence is readable', () async {
// A repo with one unreadable file must not lose coverage: a hit stands,
// because evidence that was read is evidence. Skipping every case for the

95
test/unresolved_test.dart Normal file
View file

@ -0,0 +1,95 @@
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:test/test.dart';
/// The fixture plus one file whose references do not resolve: it constructs
/// `NeverDispatched` without importing it, and imports a file that is missing.
String _corpusWithUnresolvedFile() {
final from = Directory('test/fixtures/stuck_loading');
final to = Directory.systemTemp.createTempSync('pipe-unresolved');
for (final entity in from.listSync(recursive: true)) {
final relative = entity.path.substring(from.path.length + 1);
final target = '${to.path}/$relative';
if (entity is Directory) {
Directory(target).createSync(recursive: true);
} else if (entity is File) {
File(target)
..createSync(recursive: true)
..writeAsBytesSync(entity.readAsBytesSync());
}
}
File('${to.path}/lib/unresolved_ui.dart').writeAsStringSync('''
import 'widgets_that_do_not_exist.dart';
// Unresolved, `NeverDispatched()` parses as a method call rather than a
// construction, so the dispatch below is invisible to the analysis.
void tapNever(dynamic bloc) => bloc.add(NeverDispatched());
''');
return to.path;
}
void main() {
test(
'a finding an unresolved file could refute is not high confidence',
() async {
// Every pipe finding is a whole-program absence claim, and an unresolved
// reference anywhere can hide the evidence that refutes it. On a real app
// whose package:flutter could not be read (40k analyzer errors), check
// reported findings at high confidence with no warning at all.
final analysis = await analyzeProject(
await loadProject(_corpusWithUnresolvedFile()),
concurrency: 1,
);
try {
final findings = analysis.report.findings;
final dead = findings.singleWhere(
(f) =>
f.rule == ruleDeadHandler && f.data['event'] == 'NeverDispatched',
);
expect(dead.confidence, 'medium');
expect(dead.data['confidenceLowered'], 'unresolved-references');
// The unresolved file never mentions UnhandledTap, so it cannot hide
// evidence about it: one stray error must not bury every finding.
final untouched = findings.singleWhere(
(f) =>
f.rule == ruleEventWithoutHandler &&
f.data['event'] == 'UnhandledTap',
);
expect(untouched.confidence, 'high');
expect(untouched.data.containsKey('confidenceLowered'), isFalse);
expect(
analysis.report.notes.join('\n'),
allOf(
contains('unresolved references'),
contains('unresolved_ui.dart'),
contains('1 finding(s)'),
),
);
} finally {
await analysis.dispose();
}
},
);
test('a fully resolved project keeps its confidence', () async {
final analysis = await analyzeProject(
await loadProject('test/fixtures/stuck_loading'),
concurrency: 1,
);
try {
expect(
analysis.report.findings.where((f) => f.confidence == 'high'),
isNotEmpty,
);
expect(
analysis.report.findings.where(
(f) => f.data.containsKey('confidenceLowered'),
),
isEmpty,
);
} finally {
await analysis.dispose();
}
});
}

View file

@ -1,9 +1,12 @@
#!/usr/bin/env python3
"""Independent adjudicator for pipe's two highest-volume rules.
"""Adjudicator for pipe's two highest-volume rules.
Usage:
python3 tool/adjudicate.py <repo-path> [--format json]
Exit status: 0 when the controls pass, 1 when they fail or cannot discriminate
(NO SIGNAL). A CI step must not read an uncertified run as a pass.
Precision claims are only meaningful if the thing doing the judging can
demonstrate it discriminates. So this script prints CONTROLS first: it must find
a UI type test for every variant pipe calls *rendered*, and a construction for
@ -18,7 +21,10 @@ dead-handler : event E is never constructed or dispatched in any file
other than the one that declares E.
It classifies every mention of every state/event name by regex on the source
line, so it does not trust pipe's own bookkeeping. It does deliberately NOT
line, so it does not trust pipe's evidence judgements. It is only partly
independent, though: the list of unions, variants, events and owners to check
comes from pipe's own `state` output, so a variant pipe fails to enumerate is
never checked here at all. It does deliberately NOT
judge UX severity: whether an unrendered marker state matters is a judgement,
and whether an unbound handler is worth deleting is a judgement. Only the
factual claims are adjudicated.
@ -400,9 +406,10 @@ def main() -> int:
),
}
certified = result["controls"]["controls_meaningful"] and result["controls"]["controls_pass"]
if args.format == "json":
print(json.dumps(result, indent=2))
return 0
return 0 if certified else 1
c = result["controls"]
print(f"repo {root}")
@ -433,7 +440,7 @@ def main() -> int:
print(f" CONTRADICTED {bad}")
print()
print("caveat: " + result["caveat"])
return 0
return 0 if certified else 1
if __name__ == "__main__":

634
tool/cpp_front.py Normal file
View file

@ -0,0 +1,634 @@
#!/usr/bin/env python3
"""Experimental C++ front end: writes a pipe state model from clang's AST.
Usage:
tool/cpp_front.py <project-root> [--build-dir DIR] [--include src,include]
[--limit N] [--jobs N] [-o model.json]
pipe check --model model.json
pipe's rules read only the state model, so this front end never touches them.
It uses libclang's *resolved* AST: every enumerator reference is linked to its
EnumConstantDecl, so identity is exact rather than matched by name.
What it maps (scoped enums only, `enum class`, declared in the project):
union an `enum class`; variants are its enumerators
emission a reference that PRODUCES the value: assigned to a field or
variable, stored into an atomic, passed, returned, initialised
observation a reference that EXAMINES the value: a `case` label, `==`, `!=`
(these fill pipe's "rendered" set: something branches on it)
swallowed a member writes a busy enumerator (Starting, Loading, ...) to a
failure field, then has a try whose catch writes no state and does not
rethrow (loading-not-reset)
Consequences worth knowing before reading the findings:
- `emitted-not-rendered` here means "produced, but nothing ever branches on or
compares it". A value that reaches the user through a lookup table or
reflection (magic_enum) is invisible to this, so treat those as leads.
- `rendered-not-emitted` means "compared against, but never produced anywhere":
a dead branch, unless the value comes from a cast (`static_cast<E>(n)`), which
is recorded as an indirect emission so the rule suppresses itself.
- The rule messages still speak of "UI"; for C++ read "nothing observes it".
Requires Homebrew LLVM's libclang and its Python bindings (found automatically).
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import shlex
import subprocess
import sys
from collections import Counter, defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
LLVM = Path(os.environ.get("PIPE_LLVM", "/opt/homebrew/opt/llvm"))
def _load_cindex():
for candidate in sorted(glob.glob(str(LLVM / "lib/python3.*/site-packages")), reverse=True):
if candidate not in sys.path:
sys.path.insert(0, candidate)
import clang.cindex as cindex # noqa: E402
# Pool workers are reused, and libclang may be configured only once.
if not cindex.Config.loaded:
cindex.Config.set_library_file(str(LLVM / "lib/libclang.dylib"))
return cindex
_SDK = None
BUSY = re.compile(
r"^(Loading|Starting|StartingUp|Initializing|Initialising|Connecting|Stopping|"
r"ShuttingDown|Busy|Pending|InProgress|Processing|Saving|Updating|Creating|"
r"Syncing|Indexing|Warming|WarmingUp|Draining|Migrating|Recovering)$"
)
# Kinds that only wrap an expression; the classifier looks through them.
_TRANSPARENT = {
"UNEXPOSED_EXPR",
"PAREN_EXPR",
"CSTYLE_CAST_EXPR",
"CXX_FUNCTIONAL_CAST_EXPR",
"CONDITIONAL_OPERATOR",
"INIT_LIST_EXPR",
}
def _resource_dir() -> str:
return subprocess.check_output([str(LLVM / "bin/clang"), "-print-resource-dir"], text=True).strip()
def _sdk_root() -> str | None:
"""Apple's compiler finds the macOS SDK itself; libclang needs it spelled out,
or every standard header (<chrono>, sqlite3.h) is missing."""
try:
return subprocess.check_output(["xcrun", "--show-sdk-path"], text=True).strip()
except (OSError, subprocess.CalledProcessError):
return None
def _args_for(entry: dict, resource_dir: str) -> list[str]:
"""Compiler flags only: drop wrappers (ccache), the compiler, the source,
and output/dependency options that make no sense for a syntax-only parse."""
args = entry.get("arguments") or shlex.split(entry["command"])
while args and (os.path.basename(args[0]) in ("ccache", "sccache", "distcc")
or not args[0].startswith("-") and args[0] != entry["file"]
and os.path.basename(args[0]) in ("c++", "g++", "clang++", "cc", "gcc", "clang")):
args = args[1:]
out: list[str] = []
skip = False
directory = entry["directory"]
# Include paths are relative to the build directory, but libclang resolves
# them against this process's working directory: made absolute, or headers
# like third_party/... silently go missing.
path_flags = ("-I", "-isystem", "-iquote", "-idirafter", "-include")
absolute = []
pending = None
for arg in args:
if pending is not None:
absolute.append(arg if os.path.isabs(arg) else os.path.normpath(os.path.join(directory, arg)))
pending = None
continue
if arg in path_flags:
absolute.append(arg)
pending = arg
continue
for flag in path_flags:
if arg.startswith(flag) and len(arg) > len(flag) and flag != "-include":
value = arg[len(flag):]
if not os.path.isabs(value):
arg = flag + os.path.normpath(os.path.join(directory, value))
break
absolute.append(arg)
args = absolute
for arg in args:
if skip:
skip = False
continue
if arg in ("-o", "-MF", "-MT", "-MQ"):
skip = True
continue
if arg in ("-c", "-MD", "-MMD", "-fdiagnostics-color=always") or \
arg.startswith(("-o", "-MF", "-MT", "-MQ")):
continue
if arg == entry["file"] or not arg.startswith("-") and arg.endswith((".cpp", ".cc", ".cxx")):
continue
out.append(arg)
out += ["-resource-dir", resource_dir, "-Wno-everything", "-fsyntax-only"]
sdk = _SDK
if sdk and "-isysroot" not in out:
out += ["-isysroot", sdk]
return out
def _qualified(cursor) -> str:
parts = []
c = cursor
while c is not None and c.kind.name != "TRANSLATION_UNIT":
if c.spelling:
parts.append(c.spelling)
c = c.semantic_parent
return "::".join(reversed(parts))
def _enclosing(stack, kinds):
for c in reversed(stack):
if c.kind.name in kinds:
return c
return None
def _operator(cursor) -> str | None:
try:
op = cursor.binary_operator
return op.name if op is not None else None
except AttributeError:
return None
def _excerpt(cursor, limit: int = 120) -> str:
"""A bounded, one-line excerpt of [cursor]'s source that is safe to quote
to an agent: analysed source is untrusted, so string and character literals
are elided (as pipe's Dart front end does) and the result is capped."""
parts = []
for token in cursor.get_tokens():
text = token.spelling
if token.kind.name == "LITERAL" and text[:1] in ("\"", "'") or \
token.kind.name == "LITERAL" and re.match(r'^[uUL]?8?R?["\']', text):
text = "'…'"
if token.kind.name == "COMMENT":
continue
parts.append(text)
collapsed = re.sub(r"\s+", " ", " ".join(parts)).strip()
if not collapsed:
return "<empty catch>"
return collapsed if len(collapsed) <= limit else collapsed[: limit - 3] + "..."
def _top_level_try(stack) -> bool:
"""True when the try sits directly in its function's body, not nested in a
loop, branch, block or another try."""
for i in range(len(stack) - 1, -1, -1):
kind = stack[i].kind.name
if kind in ("CXX_METHOD", "FUNCTION_DECL", "CONSTRUCTOR", "LAMBDA_EXPR"):
# stack[i+1] is the body; the try must be its direct child.
return len(stack) - 1 == i + 1 and stack[i + 1].kind.name == "COMPOUND_STMT"
if kind == "CXX_TRY_STMT":
return False
return False
def _field_owner(expr, cindex):
"""For `x.state_` / `state_` / `this->state_`: the class declaring the field."""
for node in expr.walk_preorder():
if node.kind.name == "MEMBER_REF_EXPR":
ref = node.referenced
if ref is not None and ref.kind.name == "FIELD_DECL":
return ref.semantic_parent, ref.spelling
return None, None
def analyse_tu(task):
"""Parse one translation unit; return the facts located in project files."""
file, directory, args, root, includes = task
cindex = _load_cindex()
index = cindex.Index.create()
try:
tu = index.parse(
os.path.normpath(os.path.join(directory, file)),
args=args,
options=cindex.TranslationUnit.PARSE_INCOMPLETE,
)
except cindex.TranslationUnitLoadError as error:
return {"file": file, "error": str(error)}
root = os.path.realpath(root)
prefixes = tuple(os.path.join(root, p) + os.sep for p in includes)
def in_project(cursor) -> str | None:
loc = cursor.location
if loc.file is None:
return None
path = os.path.realpath(loc.file.name)
if not path.startswith(prefixes):
return None
return os.path.relpath(path, root)
enums = {}
refs = []
busy_tries = []
def classify(stack, ref):
"""Return (role, owner, field, kind) for an enumerator reference."""
child = ref
for parent in reversed(stack):
kind = parent.kind.name
if kind in _TRANSPARENT:
child = parent
continue
if kind == "CASE_STMT":
first = next(iter(parent.get_children()), None)
if first is not None and first.hash == child.hash:
return ("observe", None, None, "case")
return ("produce", None, None, "other")
if kind == "BINARY_OPERATOR":
op = _operator(parent)
children = list(parent.get_children())
if op in ("EQ", "NE"):
return ("observe", None, None, "compare")
if op == "Assign" and len(children) == 2 and children[1].hash == child.hash:
owner, field = _field_owner(children[0], cindex)
return ("produce", owner, field, "assign")
return ("produce", None, None, "other")
if kind == "CALL_EXPR" and parent.spelling in ("store", "exchange"):
owner, field = _field_owner(parent, cindex)
return ("produce", owner, field, "assign")
if kind == "MEMBER_REF":
ref_decl = parent.referenced
if ref_decl is not None and ref_decl.kind.name == "FIELD_DECL":
return ("produce", ref_decl.semantic_parent, ref_decl.spelling, "initial")
if kind == "FIELD_DECL":
return ("produce", parent.semantic_parent, parent.spelling, "initial")
if kind in ("CALL_EXPR", "RETURN_STMT", "VAR_DECL", "COMPOUND_STMT",
"IF_STMT", "CXX_METHOD", "FUNCTION_DECL", "CONSTRUCTOR"):
return ("produce", None, None, "other")
child = parent
return ("produce", None, None, "other")
def member_name(stack):
fn = _enclosing(stack, {"CXX_METHOD", "FUNCTION_DECL", "CONSTRUCTOR",
"DESTRUCTOR", "FUNCTION_TEMPLATE", "LAMBDA_EXPR"})
if fn is None:
return "<top>", None
if fn.kind.name == "LAMBDA_EXPR":
outer = _enclosing(stack[: stack.index(fn)], {"CXX_METHOD", "FUNCTION_DECL", "CONSTRUCTOR"})
name = outer.spelling if outer is not None else "<lambda>"
return f"{name}::<lambda>", outer
return fn.spelling, fn
def scan_try(stack, try_cursor, rel):
"""A try inside a member that wrote a busy value earlier."""
body = list(try_cursor.get_children())
handlers = [c for c in body if c.kind.name == "CXX_CATCH_STMT"]
swallowed = []
for handler in handlers:
writes_state = False
rethrows = False
for node in handler.walk_preorder():
if node.kind.name == "CXX_THROW_EXPR":
rethrows = True
if node.kind.name == "DECL_REF_EXPR":
ref = node.referenced
if ref is not None and ref.kind.name == "ENUM_CONSTANT_DECL":
writes_state = True
if node.kind.name in ("CALL_EXPR",) and re.search(
r"(set|mark|fail|error|transition|reset|clear)", node.spelling or "", re.I
):
writes_state = True
if not writes_state and not rethrows:
swallowed.append((handler.location.line, _excerpt(handler), handler))
return swallowed
def visit(cursor, stack, busy_seen):
rel = in_project(cursor)
if rel is None and cursor.kind.name != "TRANSLATION_UNIT":
return
kind = cursor.kind.name
if kind == "ENUM_DECL" and cursor.is_scoped_enum() and cursor.is_definition():
name = _qualified(cursor)
enums[name] = {
"name": name,
"declaredIn": rel,
"line": cursor.location.line,
"variants": [c.spelling for c in cursor.get_children()
if c.kind.name == "ENUM_CONSTANT_DECL"],
}
elif kind == "DECL_REF_EXPR":
ref = cursor.referenced
if ref is not None and ref.kind.name == "ENUM_CONSTANT_DECL":
parent_enum = ref.semantic_parent
if parent_enum is not None and parent_enum.is_scoped_enum():
role, owner, field, how = classify(stack, cursor)
member, fn = member_name(stack)
klass = _enclosing(stack, {"CLASS_DECL", "STRUCT_DECL", "CLASS_TEMPLATE"})
if fn is not None and fn.semantic_parent is not None and \
fn.semantic_parent.kind.name in ("CLASS_DECL", "STRUCT_DECL", "CLASS_TEMPLATE"):
klass = fn.semantic_parent
record = {
"enum": _qualified(parent_enum),
"variant": ref.spelling,
"role": role,
"how": how,
"owner": _qualified(owner) if owner is not None else None,
"field": field,
"member": member,
"memberOwner": _qualified(klass) if klass is not None else None,
"path": rel,
"line": cursor.location.line,
"col": cursor.location.column,
}
refs.append(record)
# Only the method's OWN state counts: `ev.stage = Connecting` on a
# local event object handed to a callback is not state this
# class can leave stuck.
if role == "produce" and how == "assign" and BUSY.match(ref.spelling) \
and owner is not None and klass is not None \
and _qualified(owner) == _qualified(klass):
busy_seen.append(record)
elif kind == "CXX_STATIC_CAST_EXPR" or kind == "CSTYLE_CAST_EXPR":
target = cursor.type.get_canonical()
decl = target.get_declaration()
if decl is not None and decl.kind.name == "ENUM_DECL" and decl.is_scoped_enum():
children = list(cursor.get_children())
if children and children[-1].type.get_canonical().kind.name != "ENUM":
refs.append({
"enum": _qualified(decl), "variant": None, "role": "cast", "how": "cast",
"owner": None, "field": None, "member": member_name(stack)[0],
"memberOwner": None, "path": rel, "line": cursor.location.line,
"col": cursor.location.column,
})
elif kind == "CXX_TRY_STMT" and busy_seen and _top_level_try(stack):
# (a top-level try of the method: nested tries are local fallbacks,
# like a JSON parse guarded with `catch (...) { skip = false; }`)
member, fn = member_name(stack)
for busy in busy_seen:
if busy["member"] != member:
continue
# `member#index` among every catch of the enclosing function, as
# the Dart front end does: stable when code outside it moves.
catches = [c for c in fn.walk_preorder() if c.kind.name == "CXX_CATCH_STMT"] \
if fn is not None else []
for line, handler, clause in scan_try(stack, cursor, rel):
index = next((i for i, c in enumerate(catches) if c == clause), -1)
busy_tries.append({
"owner": busy["owner"], "member": member, "path": rel,
"line": line, "handler": handler, "site": f"{member}#{index}",
})
break
new_busy = busy_seen
if kind in ("CXX_METHOD", "FUNCTION_DECL", "CONSTRUCTOR", "LAMBDA_EXPR"):
new_busy = []
stack.append(cursor)
for child in cursor.get_children():
visit(child, stack, new_busy)
stack.pop()
visit(tu.cursor, [], [])
errors = [f"{d.location.file}:{d.location.line}: {d.spelling}"
for d in tu.diagnostics if d.severity >= 3]
return {"file": file, "enums": list(enums.values()), "refs": refs, "tries": busy_tries,
"errors": len(errors), "errorSamples": errors[:3]}
def build_model(root: Path, results: list[dict], includes: list[str]) -> dict:
enums: dict[str, dict] = {}
seen_refs = set()
refs = []
tries = {}
failed = []
for result in results:
if "error" in result:
failed.append(result["file"])
continue
for enum in result["enums"]:
enums.setdefault(enum["name"], enum)
for ref in result["refs"]:
key = (ref["path"], ref["line"], ref["col"], ref["enum"], ref["variant"])
if key in seen_refs:
continue
seen_refs.add(key)
refs.append(ref)
for t in result["tries"]:
tries.setdefault((t["path"], t["line"]), t)
by_enum = defaultdict(list)
for ref in refs:
by_enum[ref["enum"]].append(ref)
unions, coverage, emissions, renders = [], [], [], []
for name, enum in sorted(enums.items()):
if len(enum["variants"]) < 2:
continue
erefs = by_enum.get(name, [])
owners = Counter(r["owner"] for r in erefs if r["role"] == "produce" and r["owner"])
# The class whose field holds this state; without one it is a plain value
# type (a mode flag, a return code), not state anyone could leave stale.
if not owners:
continue
owner = owners.most_common(1)[0][0]
variants = enum["variants"]
emitted, initial, observed = set(), set(), set()
observe_sites = 0
emit_sites = 0
for r in erefs:
if r["role"] == "observe":
observed.add(r["variant"])
observe_sites += 1
renders.append({
"path": r["path"], "line": r["line"], "widget": r["how"],
"stateType": name,
"branches": [{"variant": r["variant"], "line": r["line"], "trivial": False}],
})
elif r["role"] in ("produce", "cast"):
emit_sites += 1
if r["variant"]:
emitted.add(r["variant"])
if r["how"] == "initial":
initial.add(r["variant"])
emissions.append({
"path": r["path"], "line": r["line"], "owner": owner,
"member": r["member"], "kind": "assign" if r["how"] == "assign" else "emit",
"confidence": "exact" if r["role"] == "produce" else "indirect",
"variants": [r["variant"]] if r["variant"] else [],
"snippet": f"{r['field'] or ''} = {name.split('::')[-1]}::{r['variant']}"
if r["variant"] else f"static_cast<{name.split('::')[-1]}>(...)",
# A class holds several enums: name the union so a cast to one
# does not make "never emitted" unprovable for the others.
"stateType": name,
})
union = {
"name": name, "declaredIn": enum["declaredIn"], "style": "enumUnion",
"owner": owner, "ownerKind": "Cubit",
"variants": [{"name": v, "declaredIn": enum["declaredIn"]} for v in variants],
}
unions.append(union)
coverage.append({
"union": name, "owner": owner, "style": "enumUnion",
"declared": sorted(variants), "emitted": sorted(emitted), "initial": sorted(initial),
"rendered": sorted(observed), "renderedInBuilder": [], "trivialInBuilder": [],
"emissionSites": emit_sites, "renderSites": observe_sites,
})
owners = {u["owner"] for u in unions}
swallowed = []
for (path, line), t in sorted(tries.items()):
if t["owner"] not in owners:
continue
swallowed.append({
"owner": t["owner"], "member": t["member"], "path": path, "line": line,
"site": t["site"], "handler": t["handler"],
})
# Files outside an owner's own declaration that mention it: pipe uses these
# to tell a dormant (unwired) union from a partly observed one.
references = defaultdict(set)
for r in refs:
if r["role"] == "observe" and r["enum"] in {u["name"] for u in unions}:
owner = next(u["owner"] for u in unions if u["name"] == r["enum"])
references[owner].add(r["path"])
return {
"root": str(root),
"directories": includes,
"frontEnd": {"name": "pipe-cpp-front (experimental)", "failedTranslationUnits": failed},
"unions": unions,
"eventUnions": [],
"emissions": emissions,
"renders": renders,
"handlers": [],
"dispatches": [],
"coverage": coverage,
"ownerReferences": {k: sorted(v) for k, v in references.items()},
"flagStates": [],
"riverpod": {"providers": [], "sites": []},
"constructedByFile": {},
"swallowedFailures": swallowed,
"unsettledFlags": [],
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("root")
parser.add_argument("--build-dir", help="directory holding compile_commands.json")
parser.add_argument("--include", default="src,include",
help="project subdirectories whose code is analysed")
parser.add_argument("--limit", type=int)
parser.add_argument("--match", help="only translation units whose path contains this")
parser.add_argument("--jobs", type=int, default=os.cpu_count())
parser.add_argument("-o", "--output", default="-")
args = parser.parse_args()
root = Path(args.root).resolve()
db_dir = Path(args.build_dir) if args.build_dir else root
db_path = db_dir / "compile_commands.json"
if not db_path.is_file():
print(f"cpp-front: no compile database at {db_path}; generate one "
f"(meson/CMake write it to the build dir) and pass --build-dir",
file=sys.stderr)
return 2
database = json.loads(db_path.read_text())
includes = [p for p in args.include.split(",") if p]
resource_dir = _resource_dir()
global _SDK
_SDK = _sdk_root()
tasks = []
seen = set()
for entry in database:
# Resolved on both sides: a symlinked path (macOS /var -> /private/var)
# otherwise lands "outside" the resolved root and the TU is skipped.
path = os.path.realpath(os.path.join(entry["directory"], entry["file"]))
if path in seen or not path.endswith((".cpp", ".cc", ".cxx")):
continue
rel = os.path.relpath(path, root)
if not any(rel.startswith(p + os.sep) for p in includes):
continue
if args.match and args.match not in rel:
continue
seen.add(path)
tasks.append((entry["file"], entry["directory"], _args_for(entry, resource_dir),
str(root), includes))
if args.limit:
tasks = tasks[: args.limit]
if not tasks:
# An empty model reads as a clean project; refuse instead.
print(f"cpp-front: no translation unit under {', '.join(includes)} in "
f"{db_dir / 'compile_commands.json'}; nothing was analysed", file=sys.stderr)
return 2
results = []
with ProcessPoolExecutor(max_workers=args.jobs) as pool:
futures = [pool.submit(analyse_tu, t) for t in tasks]
for i, future in enumerate(as_completed(futures), 1):
results.append(future.result())
if i % 25 == 0 or i == len(tasks):
print(f"cpp-front: {i}/{len(tasks)} translation units", file=sys.stderr)
model = build_model(root, results, includes)
# Sources the build does not compile (optional backends, benchmarks) were
# never analysed, so evidence in them is missing. Recorded as unresolved with
# the names they mention, pipe lowers confidence for any finding naming them.
unanalysed = []
names: set[str] = set()
for include in includes:
for path in sorted((root / include).rglob("*")):
if path.suffix not in (".cpp", ".cc", ".cxx") or os.path.realpath(path) in seen:
continue
unanalysed.append(os.path.relpath(path, root))
names.update(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", path.read_text(errors="replace")))
failed_files = [os.path.normpath(f) for f in model["frontEnd"]["failedTranslationUnits"]]
# The same coverage block the Dart front end writes: every report built from
# this model states how much of the project it covers.
model["scanCoverage"] = {
"discovered": len(tasks) + len(unanalysed),
"selected": len(tasks),
"resolved": len(tasks) - len(failed_files),
"failed": len(failed_files),
"skipped": len(unanalysed),
}
if unanalysed or failed_files:
files = unanalysed + failed_files
model["unresolved"] = {
"files": files,
"analysed": len(tasks) - len(failed_files),
"first": f"{files[0]}: not compiled by this build, so not analysed",
"names": sorted(names),
}
failed = model["frontEnd"]["failedTranslationUnits"]
errors = sum(r.get("errors", 0) for r in results)
for r in results:
for sample in r.get("errorSamples", [])[:1]:
print(f"cpp-front: clang error in {r['file']}: {sample}", file=sys.stderr)
print(f"cpp-front: {len(model['unions'])} state enums, {len(model['emissions'])} "
f"emissions, {len(model['renders'])} observations, "
f"{len(model['swallowedFailures'])} swallowed failures; "
f"{len(failed)} TU(s) failed, {errors} clang error(s)", file=sys.stderr)
text = json.dumps(model, indent=2)
if args.output == "-":
print(text)
else:
Path(args.output).write_text(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())