merge/audit-and-state-map #7
40 changed files with 4933 additions and 2029 deletions
750
README.md
750
README.md
|
|
@ -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
98
docs/design.md
Normal 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
56
docs/development.md
Normal 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
216
docs/queries.md
Normal 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
83
docs/rules.md
Normal 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
166
docs/usage.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
117
lib/src/cli.dart
117
lib/src/cli.dart
|
|
@ -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,11 +558,7 @@ 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.
|
||||
|
|
@ -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
453
lib/src/dart_facts.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
549
lib/src/dart_provider_facts.dart
Normal file
549
lib/src/dart_provider_facts.dart
Normal 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
458
lib/src/facts.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?>),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
95
test/cpp_front_test.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
84
test/fingerprint_test.dart
Normal file
84
test/fingerprint_test.dart
Normal 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
104
test/fixtures/cpp_state/src/engine.cpp
vendored
Normal 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
|
||||
127
test/fixtures/stuck_loading/lib/base_event.dart
vendored
Normal file
127
test/fixtures/stuck_loading/lib/base_event.dart
vendored
Normal 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());
|
||||
}
|
||||
33
test/fixtures/stuck_loading/lib/flag_cubit.dart
vendored
33
test/fixtures/stuck_loading/lib/flag_cubit.dart
vendored
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
test/fixtures/stuck_loading/lib/freezed_style.dart
vendored
Normal file
41
test/fixtures/stuck_loading/lib/freezed_style.dart
vendored
Normal 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());
|
||||
}
|
||||
17
test/fixtures/stuck_loading/lib/freezed_ui.dart
vendored
Normal file
17
test/fixtures/stuck_loading/lib/freezed_ui.dart
vendored
Normal 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',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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
116
test/model_cli_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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
61
test/sdk_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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', () {
|
||||
|
|
|
|||
44
test/state_map_roundtrip_test.dart
Normal file
44
test/state_map_roundtrip_test.dart
Normal 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
109
test/subset_scan_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
95
test/unresolved_test.dart
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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
634
tool/cpp_front.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue